diff --git a/.gitignore b/.gitignore index 42414bb3..445ae763 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ md-backup/ /docs/performance-hindrances.md /docs/release-branch-strategy.md /docs/large-file-code-smell-review.md +/docs/rsvp-expressive-reading-review.md # Android *.apk diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpExpressivePlaybackDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpExpressivePlaybackDeviceTest.kt new file mode 100644 index 00000000..f04e556a --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpExpressivePlaybackDeviceTest.kt @@ -0,0 +1,71 @@ +package com.kairo.reader.ui.rsvp + +import android.os.SystemClock +import androidx.activity.compose.setContent +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.Chapter +import com.kairo.reader.core.model.RsvpProfile +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.model.defaultConfig +import com.kairo.reader.core.rsvp.ComprehensionRsvpEngine +import com.kairo.reader.core.rsvp.RsvpGenerationOptions +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import com.kairo.reader.core.tokenization.Tokenizer +import com.kairo.reader.ui.theme.KairoTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Exercises authored HTML through the repository and real grouped Narrative playback. */ +@RunWith(AndroidJUnit4::class) +class RsvpExpressivePlaybackDeviceTest { + @Test + fun authoredStressAndContextualThoughtsReachTheDisplayWithoutLosingWords() { + val text = "I want that one. I said Tuesday, then waited before opening the door." + val tokens = Tokenizer().tokenize(Chapter(0, null, "

${text.replace("Tuesday", "Tuesday")}

", text)) + val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + val config = RsvpProfile.NARRATIVE.defaultConfig().copy( + maxWordsPerUnit = 3, + maxCharsPerUnit = 32, + startDelayMs = 0L, + endDelayMs = 0L, + rampUpFrames = 0, + rampDownFrames = 0, + ) + val fixture = RsvpDeviceFixture(tokens, config) + fixture.state = fixture.state.copy(book = fixture.state.book.copy(generationOptions = options)) + ActivityScenario.launch(TestActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.setContent { KairoTheme { RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) } } + } + val deadline = SystemClock.elapsedRealtime() + 20_000L + while (!fixture.finished && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20L) + assertTrue("Narrative playback finishes", fixture.finished) + val played = fixture.consumed.filter { frame -> frame.tokens.any { it.type == TokenType.WORD } } + assertTrue("Fixture exercises grouping", played.any { frame -> frame.tokens.count { it.type == TokenType.WORD } > 1 }) + assertEquals( + tokens.indices.filter { tokens[it].type == TokenType.WORD }, + played.flatMap { frame -> + (frame.displayOriginalStartIndex until frame.displayOriginalEndExclusive).filter { tokens[it].type == TokenType.WORD } + }.distinct(), + ) + val that = played.first { frame -> frame.tokens.any { it.text == "that" } } + assertEquals(0, that.phraseStartTokenIndex) + val before = played.first { frame -> frame.tokens.any { it.text == "before" } } + assertEquals(tokens.indexOfFirst { it.text == "before" }, before.phraseStartTokenIndex) + val accent = played.first { frame -> frame.tokens.any { it.authorEmphasis } } + val neutral = ComprehensionRsvpEngine().generateFrames(tokens.map { it.copy(authorEmphasis = false) }, 0, config, options) + .first { it.originalTokenIndex == accent.originalTokenIndex } + assertTrue("Authored word retains extra exposure in playback", accent.durationMs > neutral.durationMs) + val consumedIndex = fixture.consumed.indexOf(accent) + assertTrue(consumedIndex > 0) + assertTrue( + "The scheduler preserves that exposure", + fixture.consumedAtMs[consumedIndex] - fixture.consumedAtMs[consumedIndex - 1] >= accent.durationMs, + ) + } + } +} diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpStartupDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpStartupDeviceTest.kt new file mode 100644 index 00000000..f6ad270c --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpStartupDeviceTest.kt @@ -0,0 +1,230 @@ +package com.kairo.reader.ui.rsvp + +import android.os.SystemClock +import android.util.Log +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.click +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kairo.reader.R +import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.BlinkMode +import com.kairo.reader.core.model.BookId +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.RsvpProfile +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.model.defaultConfig +import com.kairo.reader.core.rsvp.RsvpGenerationOptions +import com.kairo.reader.core.tokenization.Tokenizer +import com.kairo.reader.data.rsvp.RsvpFrameRepository +import com.kairo.reader.data.rsvp.RsvpFrameSet +import com.kairo.reader.sample.SampleBooks +import com.kairo.reader.ui.theme.KairoTheme +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RsvpStartupDeviceTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun slowPreparationKeepsPreviewStillThenReadsFromSelectedWordInOrder() { + val fixture = fixture(startDelayMs = 0L) + val releaseFrames = showWithBlockedPreparation(fixture) + composeRule.onNodeWithText("Start", useUnmergedTree = true).assertIsDisplayed() + // Keep preparation blocked beyond the preview's reading interval. + composeRule.mainClock.advanceTimeBy(800L) + composeRule.runOnIdle { + assertTrue("The loading preview must not be consumed", fixture.consumed.isEmpty()) + } + composeRule.onNodeWithText("Start", useUnmergedTree = true).assertIsDisplayed() + releaseFrames.complete(Unit) + composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.finished } + assertEquals(listOf(2, 3, 4), fixture.consumed.map { it.originalTokenIndex }) + } + + @Test + fun coldPreparationOverlapsStartPauseWithoutSkippingTheFirstWord() { + val fixture = fixture(startDelayMs = 1000L) + val releaseFrames = showWithBlockedPreparation(fixture) + composeRule.onNodeWithText("Start", useUnmergedTree = true).assertIsDisplayed() + composeRule.mainClock.autoAdvance = false + // The app measures real preparation time; playback uses Compose's test clock. + SystemClock.sleep(1200L) + releaseFrames.complete(Unit) + composeRule.waitUntil(timeoutMillis = 10_000L) { + composeRule.mainClock.advanceTimeByFrame() + fixture.loads.get() > 0 + } + composeRule.mainClock.advanceTimeByFrame() + composeRule.mainClock.advanceTimeByFrame() + assertTrue("The first word still needs its reading interval", fixture.consumed.isEmpty()) + composeRule.mainClock.advanceTimeBy(400L) + composeRule.runOnIdle { + assertTrue("Do not add another full startup pause after loading", fixture.consumed.isNotEmpty()) + assertEquals(2, fixture.consumed.first().originalTokenIndex) + } + composeRule.mainClock.autoAdvance = true + composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.finished } + assertEquals(listOf(2, 3, 4), fixture.consumed.map { it.originalTokenIndex }) + } + + @Test + fun preparedOpeningPlaysBeforeChapterArrivesAndWaitsAtItsBoundaryWithoutRepeating() { + val fixture = fixture(startDelayMs = 0L, tokens = List(120) { Token("reading", TokenType.WORD) }) + val releaseFrames = showWithBlockedPreparation(fixture) + composeRule.waitUntil(timeoutMillis = 15_000L) { fixture.consumed.size >= 64 } + composeRule.mainClock.advanceTimeBy(1000L) + composeRule.runOnIdle { + assertEquals("Playback must start before full-chapter preparation completes", 0, fixture.loads.get()) + assertEquals((2 until 66).toList(), fixture.consumed.map { it.originalTokenIndex }) + assertTrue(!fixture.finished) + } + releaseFrames.complete(Unit) + composeRule.waitUntil(timeoutMillis = 20_000L) { fixture.finished } + assertEquals((2 until 120).toList(), fixture.consumed.map { it.originalTokenIndex }) + } + + @Test + fun pausingTheOpeningKeepsTheSameWordWhenFullChapterArrives() { + val fixture = fixture(startDelayMs = 0L, tokens = List(120) { Token("word$it", TokenType.WORD) }) + val releaseFrames = showWithBlockedPreparation(fixture) + composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.consumed.size >= 2 } + composeRule.onRoot().performTouchInput { click() } + val play = composeRule.activity.getString(R.string.content_desc_play) + composeRule.waitUntil(timeoutMillis = 3000L) { + composeRule.onAllNodesWithContentDescription(play).fetchSemanticsNodes().isNotEmpty() + } + val focus = composeRule.onNodeWithTag("rsvp-focus-word", useUnmergedTree = true) + val pausedText = focus.fetchSemanticsNode().config[SemanticsProperties.Text].single().text + releaseFrames.complete(Unit) + composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.loads.get() >= 2 } + focus.assertTextEquals(pausedText) + } + + @Test + fun startingNearTheEndDoesNotWaitForOrReplayTheWholeChapter() { + val fixture = fixture(startDelayMs = 0L, tokens = List(120) { Token("reading", TokenType.WORD) }) + fixture.state = fixture.state.copy(book = fixture.state.book.copy(startIndex = 115, sessionStartIndex = 115)) + val releaseFrames = showWithBlockedPreparation(fixture) + composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.finished } + assertEquals(0, fixture.loads.get()) + assertEquals((115 until 120).toList(), fixture.consumed.map { it.originalTokenIndex }) + releaseFrames.complete(Unit) + composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.loads.get() > 0 } + composeRule.mainClock.advanceTimeBy(1000L) + assertEquals((115 until 120).toList(), fixture.consumed.map { it.originalTokenIndex }) + } + + @Test + fun measureFirstPlaybackOnAColdLargeEnglishChapter() { + val paragraph = Tokenizer().tokenize(SampleBooks.starterBook().chapters[2]) + val fixture = RsvpDeviceFixture(List(8000) { paragraph[it % paragraph.size] }, RsvpProfile.BALANCED.defaultConfig()) + fixture.state = fixture.state.copy(book = fixture.state.book.copy(generationOptions = RsvpGenerationOptions.fromLanguageTag("en"))) + val startedAt = SystemClock.elapsedRealtime() + composeRule.setContent { + KairoTheme { RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) } + } + composeRule.waitUntil(timeoutMillis = 15_000L) { fixture.consumed.isNotEmpty() } + Log.i( + "RsvpStartupBenchmark", + "8000-token UI: first consumed=${fixture.consumedAtMs.first() - startedAt} ms, chapter loads=${fixture.loads.get()}", + ) + composeRule.waitUntil(timeoutMillis = 15_000L) { fixture.loads.get() > 0 } + } + + @Test + fun replayDuringOpeningCanReachBeforeTheLaunchPositionOnceChapterIsReady() { + val fixture = startBufferedMidChapter() + + composeRule.onRoot().performTouchInput { doubleClick() } + + composeRule.waitUntil(timeoutMillis = 3000L) { (fixture.position?.tokenIndex ?: 80) < 80 } + assertTrue("Replay must reach text before the opening buffer", requireNotNull(fixture.position).tokenIndex < 80) + } + + @Test + fun backwardSwipeDuringOpeningCanReachBeforeTheLaunchPositionOnceChapterIsReady() { + val fixture = startBufferedMidChapter() + + composeRule.onRoot().performTouchInput { + swipe(Offset(width * 0.85f, height * 0.4f), Offset(width * 0.15f, height * 0.4f), durationMillis = 160L) + } + + composeRule.waitUntil(timeoutMillis = 3000L) { (fixture.position?.tokenIndex ?: 80) < 80 } + assertTrue("Swipe must reach text before the opening buffer", requireNotNull(fixture.position).tokenIndex < 80) + } + + private fun startBufferedMidChapter(): RsvpDeviceFixture { + val fixture = fixture(startDelayMs = 0L, tokens = List(200) { Token("word$it", TokenType.WORD) }) + fixture.state = fixture.state.copy( + book = fixture.state.book.copy(startIndex = 80, sessionStartIndex = 80), + profile = fixture.state.profile.copy(config = fixture.state.profile.config.copy(tempoMsPerWord = 600L)), + ) + val releaseFrames = showWithBlockedPreparation(fixture) + composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.consumed.isNotEmpty() } + assertEquals(0, fixture.loads.get()) + releaseFrames.complete(Unit) + composeRule.waitUntil(timeoutMillis = 10_000L) { fixture.loads.get() > 0 } + composeRule.waitForIdle() + assertTrue("Exercise navigation before the opening has been consumed", fixture.consumed.size < 64) + return fixture + } + + private fun fixture( + startDelayMs: Long, + tokens: List = listOf("Earlier", "Before", "Start", "Next", "Last").map { Token(it, TokenType.WORD) }, + ): RsvpDeviceFixture = + RsvpDeviceFixture( + tokens, + RsvpConfig( + tempoMsPerWord = 100L, + enablePhraseChunking = false, + blinkMode = BlinkMode.OFF, + startDelayMs = startDelayMs, + endDelayMs = 0L, + rampUpFrames = 0, + rampDownFrames = 0, + ), + startIndex = 2, + ) + + private fun showWithBlockedPreparation(fixture: RsvpDeviceFixture): CompletableDeferred { + val releaseFrames = CompletableDeferred() + val repository = fixture.dependencies.frameRepository + val delayedRepository = object : RsvpFrameRepository by repository { + override suspend fun getSeekableFrames( + bookId: BookId, + chapterIndex: Int, + config: RsvpConfig, + startIndex: Int, + options: RsvpGenerationOptions, + ): RsvpFrameSet { + releaseFrames.await() + return repository.getSeekableFrames(bookId, chapterIndex, config, startIndex, options) + } + } + composeRule.setContent { + KairoTheme { + RsvpScreen(fixture.state, fixture.callbacks, RsvpScreenDependencies(delayedRepository)) + } + } + return releaseFrames + } +} diff --git a/app/src/main/java/com/kairo/reader/core/linguistics/WordAnalyzer.kt b/app/src/main/java/com/kairo/reader/core/linguistics/WordAnalyzer.kt index 9770a954..b221462c 100644 --- a/app/src/main/java/com/kairo/reader/core/linguistics/WordAnalyzer.kt +++ b/app/src/main/java/com/kairo/reader/core/linguistics/WordAnalyzer.kt @@ -739,33 +739,14 @@ object ClauseDetector { ): Double { if (nextWord == null) return 0.0 val lower = word.lowercase() - return when { - lower in setOf("a", "an", "the") -> 0.9 - lower in setOf("my", "your", "his", "her", "its", "our", "their") -> 0.85 - lower in phraseLeaders -> 0.75 - lower in setOf( - "of", - "to", - "in", - "on", - "at", - "by", - "for", - "with", - "from", - "into", - "onto", - "upon", - "about", - "through", - "between", - "among", - "against", - "toward", - "towards", - ) -> 0.7 - lower in setOf("very", "quite", "rather", "too", "so", "really", "almost", "nearly") -> 0.8 - lower in setOf("will", "would", "can", "could", "should", "must", "may", "might") -> 0.65 + return when (lower) { + "a", "an", "the" -> 0.9 + "my", "your", "his", "her", "its", "our", "their" -> 0.85 + in phraseLeaders -> 0.75 + "of", "to", "in", "on", "at", "by", "for", "with", "from", + "into", "onto", "upon", "about", "through", "between", "among", "against", "toward", "towards" -> 0.7 + "very", "quite", "rather", "too", "so", "really", "almost", "nearly" -> 0.8 + "will", "would", "can", "could", "should", "must", "may", "might" -> 0.65 else -> 0.0 } } @@ -816,10 +797,11 @@ object DialogueAnalyzer { val lower = words.map { it.lowercase() } val hasVerb = lower.any { it in speakerVerbs } + if (!hasVerb) return false val text = lower.joinToString(" ") val matchesPattern = speakerPatterns.any { it.containsMatchIn(text) } - return hasVerb && matchesPattern + return matchesPattern } fun isSpeakerVerb(word: String): Boolean { diff --git a/app/src/main/java/com/kairo/reader/core/model/Models.kt b/app/src/main/java/com/kairo/reader/core/model/Models.kt index bfc6073f..cbe152ee 100644 --- a/app/src/main/java/com/kairo/reader/core/model/Models.kt +++ b/app/src/main/java/com/kairo/reader/core/model/Models.kt @@ -107,6 +107,8 @@ data class Token( val isSubwordChunk: Boolean = false, /** If this token is inside a link, the target chapter index */ val linkChapterIndex: Int? = null, + /** Local semantic emphasis from source markup; independent of difficulty and display styling. */ + val authorEmphasis: Boolean = false, ) data class RsvpConfig( diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt index 0809d909..a6228c30 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt @@ -6,6 +6,7 @@ import com.kairo.reader.core.model.RsvpResumeCursor import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType import com.kairo.reader.core.rsvp.analysis.ReadingDemandAnalyzer +import com.kairo.reader.core.rsvp.analysis.RsvpPhraseBoundaries import com.kairo.reader.core.rsvp.analysis.RsvpTokenAnalysis import com.kairo.reader.core.rsvp.analysis.RsvpWordPartSupport import com.kairo.reader.core.rsvp.analysis.analyzeExpandedTokens @@ -23,6 +24,7 @@ import com.kairo.reader.core.rsvp.engine.PARAGRAPH_BREAK_RETENTION_BOOST import com.kairo.reader.core.rsvp.engine.PhraseContour import com.kairo.reader.core.rsvp.engine.ProseState import com.kairo.reader.core.rsvp.engine.RhythmState +import com.kairo.reader.core.rsvp.engine.RsvpWordExpression import com.kairo.reader.core.rsvp.engine.SKIPPABLE_BOUNDARY_PUNCTUATION import com.kairo.reader.core.rsvp.engine.applyPlaybackEffects import com.kairo.reader.core.rsvp.engine.buildUnit @@ -163,7 +165,7 @@ private fun buildExpandedTokens( config: RsvpConfig, languagePolicy: RsvpLanguagePolicy, ): List = - tokens + RsvpPhraseBoundaries.annotate(tokens, languagePolicy) .subList(analysisStartIndex, tokens.size) .flatMapIndexed { index, token -> var sourceCursor = 0 @@ -399,10 +401,15 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { nextToken = expanded.getOrNull(nextCursor)?.token, nextWord = expanded.getOrNull(findFirstWordCursor(expanded, nextCursor))?.token, boundaryBefore = boundaryBefore(expanded, wordCursor), - focalSuppression = focalSuppression(wordCursor), - anticipatoryLanding = anticipatoryLanding(wordCursor), emDashAside = config.useParentheticalAside && wordCursor in analysis.emDashAsideIndices, - phraseContour = analysis.phraseContours[wordCursor] ?: PhraseContour.NONE, + wordExpressions = (wordCursor until nextCursor).filter { expanded[it].token.type == TokenType.WORD }.map { + RsvpWordExpression( + focalSuppression = focalSuppression(it), + anticipatoryLanding = anticipatoryLanding(it), + phraseContour = analysis.phraseContours[it] ?: PhraseContour.NONE, + ) + }, + languagePolicy = options.languagePolicy, prose = prose, pairedEmDashInUnit = (frameStartCursor until nextCursor).any { it in analysis.pairedEmDashIndices }, diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpPhraseAnalysis.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpPhraseAnalysis.kt index ba4345f5..dfca028f 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpPhraseAnalysis.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpPhraseAnalysis.kt @@ -96,7 +96,7 @@ internal fun analyzeExpandedTokens( } } if (config.useFocalStress) { - addFocalWord(breathGroup, focal, thoughtCues) + addFocalWord(breathGroup, focal, thoughtCues, languagePolicy) } breathGroup.clear() applyRestartContour(tier = tier, afterIndex = boundaryIndex) @@ -117,7 +117,7 @@ internal fun analyzeExpandedTokens( } TokenType.PARAGRAPH_BREAK, TokenType.PAGE_BREAK -> { if (config.useFocalStress) { - addFocalWord(breathGroup, focal, thoughtCues) + addFocalWord(breathGroup, focal, thoughtCues, languagePolicy) } breathGroup.clear() previousWord = null @@ -151,7 +151,7 @@ internal fun analyzeExpandedTokens( } } if (config.useFocalStress) { - addFocalWord(breathGroup, focal, thoughtCues) + addFocalWord(breathGroup, focal, thoughtCues, languagePolicy) } return RsvpTokenAnalysis( @@ -180,6 +180,7 @@ private fun addFocalWord( group: List, focal: MutableSet, thoughtCues: Map, + languagePolicy: RsvpLanguagePolicy, ) { if (group.isEmpty()) return val protected = group.filter { thoughtCues[it.expandedIndex]?.protectedEmphasis == true } @@ -192,43 +193,30 @@ private fun addFocalWord( return } - var bestIndex = -1 - var bestScore = Double.NEGATIVE_INFINITY - group.forEachIndexed { positionInGroup, entry -> - val score = focalScore(entry.token, positionInGroup, group.size) - if (score > bestScore || (score == bestScore && bestIndex != -1)) { - bestScore = score - bestIndex = entry.expandedIndex - } - } - if (bestIndex >= 0) { - focal += bestIndex + val focus = if (languagePolicy == RsvpLanguagePolicy.ENGLISH) confidentEnglishFocus(group) else null + if (focus != null) { + focal += focus.expandedIndex } else { + // Ambiguous prose stays neutral. Length and rarity already have recognition allowances; + // neither is evidence that the author intended rhetorical stress. group.forEach { focal += it.expandedIndex } } } -internal fun focalScore( - token: Token, - positionInGroup: Int, - groupSize: Int, -): Double { - if (token.isSubwordChunk) return Double.NEGATIVE_INFINITY - val normalized = normalizeWord(token.text) - if (normalized.isEmpty()) return Double.NEGATIVE_INFINITY - val letters = normalized.count { it.isLetterOrDigit() } - if (letters == 0) return Double.NEGATIVE_INFINITY - - val functionPenalty = if (isFunctionWord(normalized)) FOCAL_FUNCTION_WORD_PENALTY else 1.0 - val anchorBonus = if (isSemanticAnchor(normalized)) FOCAL_SEMANTIC_ANCHOR_BONUS else 1.0 - // Tiny end-weighting so the final content word of a breath wins ties. - val endWeight = 1.0 + (positionInGroup.toDouble() / (groupSize * FOCAL_END_WEIGHT_DIVISOR)) - return letters.toDouble() * functionPenalty * anchorBonus * endWeight +private fun confidentEnglishFocus(group: List): ExpandedToken? { + val candidates = group.filter { + !it.token.isSubwordChunk && + !isFunctionWord(normalizeWord(it.token.text)) && + normalizeWord(it.token.text) !in LOW_STRESS_TAILS + } + if (candidates.size == 1) return candidates.single() + val last = group.last() + val previous = group.getOrNull(group.lastIndex - 1)?.token?.text?.let(::normalizeWord) + return last.takeIf { it in candidates && previous in FOCUS_LEADS } } -private const val FOCAL_FUNCTION_WORD_PENALTY = 0.25 -private const val FOCAL_SEMANTIC_ANCHOR_BONUS = 1.5 -private const val FOCAL_END_WEIGHT_DIVISOR = 20.0 +private val FOCUS_LEADS = setOf("a", "an", "the", "my", "your", "his", "her", "our", "their", "is", "was", "were", "are") +private val LOW_STRESS_TAILS = setOf("again", "just", "really", "very", "then", "here", "there") internal fun MutableMap.mergeContour( expandedIndex: Int, diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpPhraseBoundaries.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpPhraseBoundaries.kt new file mode 100644 index 00000000..9aa19e65 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpPhraseBoundaries.kt @@ -0,0 +1,54 @@ +package com.kairo.reader.core.rsvp.analysis + +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy + +/** One conservative decision shared by grouping, thought metadata, and timing. */ +internal object RsvpPhraseBoundaries { + fun annotate(tokens: List, language: RsvpLanguagePolicy): List = + tokens.mapIndexed { index, token -> + val boundary = isBoundary(tokens, index, language) + if (token.isClauseBoundary == boundary) token else token.copy(isClauseBoundary = boundary) + } + + fun isBoundary(tokens: List, index: Int, language: RsvpLanguagePolicy): Boolean = + language == RsvpLanguagePolicy.ENGLISH && tokens[index].type == TokenType.WORD && startsClause(tokens, index) + + private fun startsClause(tokens: List, index: Int): Boolean { + val word = normalizeWord(tokens[index].text) + val previous = tokens.getOrNull(index - 1)?.takeIf { it.type == TokenType.WORD }?.text?.let(::normalizeWord) + val next = tokens.getOrNull(index + 1)?.takeIf { it.type == TokenType.WORD }?.text?.let(::normalizeWord) + val afterNext = tokens.getOrNull(index + 2)?.takeIf { it.type == TokenType.WORD }?.text?.let(::normalizeWord) + if ("$previous $word" in CONNECTED_LEADS) return false + if (word in CLEAR_CLAUSE_STARTERS) return true + if (previous == null || next == null) return false + if ("$word $next" in PAIRED_CLAUSE_STARTERS) return true + return when (word) { + "that" -> next in SUBJECTS || (next in FINITE_AUXILIARIES && previous !in COMPLEMENT_VERBS) + "who", "whom", "whose", "which" -> previous !in COMPLEMENT_VERBS && previous !in PREPOSITIONS + in TEMPORAL_STARTERS -> + next in SUBJECTS || + next in VERBAL_PARTICIPLES || + (next.endsWith("ing") && next.length > MIN_PARTICIPLE_LENGTH && afterNext in OBJECT_LEADS) + in COORDINATORS -> next in SUBJECTS && tokens.getOrNull(index - 2)?.type == TokenType.WORD + else -> false + } + } + + private const val MIN_PARTICIPLE_LENGTH = 4 + private val SUBJECTS = setOf("i", "you", "he", "she", "it", "we", "they", "there") + private val FINITE_AUXILIARIES = setOf("is", "was", "were", "has", "had", "can", "could", "will", "would") + private val COMPLEMENT_VERBS = setOf("know", "knew", "ask", "asked", "wonder", "wondered", "say", "said", "think", "thought") + private val PREPOSITIONS = setOf("of", "to", "for", "from", "with", "about", "at", "by", "in", "on") + private val CONNECTED_LEADS = setOf("even though", "as though", "as if", "so that", "such that", "now that", "in that") + private val PAIRED_CLAUSE_STARTERS = setOf("even though", "as though", "as if", "so that", "now that") + private val VERBAL_PARTICIPLES = setOf("leaving", "arriving", "speaking", "eating", "sleeping", "returning", "starting", "finishing") + private val OBJECT_LEADS = setOf("a", "an", "the", "my", "your", "his", "her", "our", "their", "it", "them") + private val TEMPORAL_STARTERS = setOf("when", "where", "whether", "while", "since", "before", "after", "until") + private val COORDINATORS = setOf("and", "but", "or", "yet", "so") + private val CLEAR_CLAUSE_STARTERS = setOf( + "because", "although", "though", "unless", "if", "whereas", "however", "therefore", "moreover", + "furthermore", "nevertheless", "meanwhile", "otherwise", "hence", "thus", "consequently", "accordingly", + ) +} diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpThoughtPlan.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpThoughtPlan.kt index 91078853..1fe2e57d 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpThoughtPlan.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpThoughtPlan.kt @@ -31,7 +31,7 @@ internal object RsvpThoughtPlan { fun finish(endExclusive: Int) { if (words.isEmpty()) return - val protected = protectedWords(words, english, previousModals) + val protected = protectedWords(words, english, previousModals, config.useProsodyPacing) val hold = integrationHold(words, config, languagePolicy) val sourceWords = words.distinctBy { it.originalIndex } val processingShare = if (sourceWords.size > 1) PROCESSING_SHARE else 0.0 @@ -86,9 +86,14 @@ internal object RsvpThoughtPlan { words: List, english: Boolean, previousModals: Set, + includeAuthorEmphasis: Boolean, ): Set = buildSet { words.forEachIndexed { index, entry -> val token = entry.token + // A split source word gets one authored accent, on its final part. + if (includeAuthorEmphasis && token.authorEmphasis && words.getOrNull(index + 1)?.originalIndex != entry.originalIndex) { + add(entry.expandedIndex) + } if (token.isSubwordChunk) return@forEachIndexed val word = normalizeWord(token.text) val previous = words.getOrNull(index - 1)?.token?.text?.let(::normalizeWord) diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPacing.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPacing.kt index ba330f35..9cf441ad 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPacing.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPacing.kt @@ -233,16 +233,15 @@ internal fun contextShapingMultiplier( internal fun transitionHoldMs( frameTokens: List, - firstWord: Token?, + lastWord: Token?, nextWord: Token?, speedStrength: Double, prosodyStrength: Double, ): Double { - if (firstWord == null || nextWord == null) return 0.0 - if (frameTokens.count { it.type == TokenType.WORD } != 1) return 0.0 - if (frameTokens.any { it.type == TokenType.PUNCTUATION }) return 0.0 + if (lastWord == null || nextWord == null) return 0.0 + if (frameTokens.lastOrNull()?.type == TokenType.PUNCTUATION) return 0.0 - val firstLower = firstWord.text.lowercase() + val firstLower = lastWord.text.lowercase() val nextLower = nextWord.text.lowercase() // Function words should glide into content words with minimal visual gap. @@ -251,7 +250,7 @@ internal fun transitionHoldMs( } // Check for tight pair patterns that should stay together mentally - if (shouldPreferHold(firstWord, nextWord)) { + if (shouldPreferHold(lastWord, nextWord)) { val hold = TRANSITION_HOLD_BASE_MS + (TRANSITION_HOLD_EXTRA_MS * speedStrength) return hold.coerceAtLeast(0.0) } @@ -265,7 +264,7 @@ internal fun transitionHoldMs( // Breath before a grammatical phrase boundary (clause-starter / joining conjunction) that // carries no punctuation. Kept present even at moderate speed via PHRASE_BREATH_BASE so the // phrasing of the inner voice is actually felt, not just a sliver at very high WPM. - if (isPhraseBreakBefore(firstLower, nextLower, nextWord)) { + if (nextWord.isClauseBoundary) { return PHRASE_BREAK_HOLD_MS * (PHRASE_BREATH_BASE + ((1.0 - PHRASE_BREATH_BASE) * speedStrength)) } @@ -302,9 +301,9 @@ internal fun isCoherencePair( } /** - * Phrase-arc shaping for a single-word frame at a grammatical (punctuation-free) phrase boundary. + * Phrase-arc shaping for each word at a grammatical (punctuation-free) phrase boundary. * - * Uses the same [isPhraseBreakBefore] detector as the breath in [transitionHoldMs], so the arc and + * Uses the same contextual boundary annotation as the breath in [transitionHoldMs], so the arc and * the breath always fire together: the word closing an intonation unit stretches slightly * (pre-boundary lengthening) and the clause-starter opening the next one gets a small onset lift * instead of being swallowed by the function-word glide. Punctuation-adjacent words are excluded — @@ -325,55 +324,18 @@ internal fun phraseBoundaryShapeMultiplier( val presence = PHRASE_BREATH_BASE + ((1.0 - PHRASE_BREATH_BASE) * speedStrength) var multiplier = 1.0 - if (nextWord != null && isPhraseBreakBefore(wordLower, nextWord.text.lowercase(), nextWord)) { + if (nextWord?.isClauseBoundary == true) { multiplier *= 1.0 + (PHRASE_PRE_BOUNDARY_LIFT * presence * prosodyStrength) } if (prevWord != null && boundaryBefore == BoundaryBefore.NONE && - isPhraseBreakBefore(prevWord.text.lowercase(), wordLower, word) + word.isClauseBoundary ) { multiplier *= 1.0 + (PHRASE_ONSET_LIFT * presence * prosodyStrength) } return multiplier } -internal fun isPhraseBreakBefore( - currentLower: String, - nextLower: String, - nextWord: Token, -): Boolean { - // Before clause starters, add a micro-pause for comprehension - if (nextLower in setOf( - "which", - "who", - "whom", - "whose", - "that", - "where", - "when", - "because", - "although", - "though", - "unless", - "until", - "while", - "after", - "before", - "if", - ) - ) { - return true - } - // Before coordinating conjunctions in longer sentences - if (nextLower in setOf("and", "but", "or", "yet", "so") && - currentLower.length > MIN_COORDINATING_BOUNDARY_WORD_CHARS && - !nextWord.isClauseBoundary - ) { - return true - } - return false -} - internal fun shouldPreferHold( prev: Token, next: Token, @@ -529,7 +491,6 @@ internal fun wordEase(word: Token, policy: RsvpLanguagePolicy = RsvpLanguagePoli private const val MAX_SPEAKER_TAG_FRAME_WORDS = 3 private const val MIN_DIALOGUE_SPEED_STRENGTH = 0.15 -private const val MIN_COORDINATING_BOUNDARY_WORD_CHARS = 3 private const val MAX_GLUE_PAIR_WORD_CHARS = 4 private const val HIGH_COHERENCE_HOLD_THRESHOLD = 0.65 private const val MAX_HIGH_COHERENCE_PREV_CHARS = 5 diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineTypes.kt b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineTypes.kt index 90be2dd4..685439ac 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineTypes.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineTypes.kt @@ -20,6 +20,13 @@ internal data class PhraseContour(val preBoundaryWeight: Double, val restartWeig } } +/** Expression stays attached to its source word even when several words share a frame. */ +internal data class RsvpWordExpression( + val focalSuppression: Double = 1.0, + val anticipatoryLanding: Double = 1.0, + val phraseContour: PhraseContour = PhraseContour.NONE, +) + internal data class UnitBuildResult(val tokens: List, val originalWordIndex: Int, val nextCursor: Int,) internal enum class BoundaryBefore { diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomization.kt b/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomization.kt index ced2c608..03a403c0 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomization.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomization.kt @@ -158,7 +158,10 @@ internal class RsvpAtomStream private constructor(val atoms: List, val } } - if (useDialogueDetection && languagePolicy == RsvpLanguagePolicy.ENGLISH) { + if (useDialogueDetection && + languagePolicy == RsvpLanguagePolicy.ENGLISH && + RsvpDialogueRole.DIALOGUE_CONTENT in roles + ) { annotateEnglishSpeakerTags( atoms = atoms, baseRoles = roles.toList(), @@ -222,13 +225,9 @@ internal class RsvpAtomStream private constructor(val atoms: List, val (start until endExclusive).all { index -> baseRoles[index] == RsvpDialogueRole.NARRATION } - if (!candidateIsNarration) return + if (!candidateIsNarration || !hasNearbyDialogue(baseRoles, start, endExclusive)) return val words = atoms.subList(start, endExclusive).map { it.token.text } - if (!DialogueAnalyzer.isSpeakerTag(words) || - !hasNearbyDialogue(baseRoles, start, endExclusive) - ) { - return - } + if (!DialogueAnalyzer.isSpeakerTag(words)) return for (index in start until endExclusive) { roles[index] = RsvpDialogueRole.SPEAKER_TAG } diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpUnitTiming.kt b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpUnitTiming.kt index 4f822c1d..24721d6e 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpUnitTiming.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpUnitTiming.kt @@ -7,6 +7,7 @@ import com.kairo.reader.core.model.RsvpConfig import com.kairo.reader.core.model.RsvpConfigConstraints import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy import com.kairo.reader.core.rsvp.analysis.RsvpReadingDemand import com.kairo.reader.core.rsvp.analysis.RsvpThoughtCue import com.kairo.reader.core.rsvp.analysis.contextShapingMultiplier @@ -30,10 +31,10 @@ import com.kairo.reader.core.rsvp.engine.EM_DASH_INTERRUPTION_PAUSE_FACTOR import com.kairo.reader.core.rsvp.engine.MIN_FRAME_MS import com.kairo.reader.core.rsvp.engine.PAGE_BREAK_RETENTION_BOOST import com.kairo.reader.core.rsvp.engine.PARAGRAPH_BREAK_RETENTION_BOOST -import com.kairo.reader.core.rsvp.engine.PhraseContour import com.kairo.reader.core.rsvp.engine.ProseState import com.kairo.reader.core.rsvp.engine.QUOTE_TRANSITION_HOLD_FRACTION import com.kairo.reader.core.rsvp.engine.RhythmState +import com.kairo.reader.core.rsvp.engine.RsvpWordExpression import com.kairo.reader.core.rsvp.engine.SENTENCE_START_HOLD_FRACTION import com.kairo.reader.core.rsvp.text.boundaryBeforeForPunctuation import com.kairo.reader.core.rsvp.text.isHardBoundary @@ -52,10 +53,9 @@ internal data class RsvpUnitTimingInput( val nextToken: Token?, val nextWord: Token?, val boundaryBefore: BoundaryBefore, - val focalSuppression: Double = 1.0, - val anticipatoryLanding: Double = 1.0, val emDashAside: Boolean = false, - val phraseContour: PhraseContour = PhraseContour.NONE, + val wordExpressions: List = emptyList(), + val languagePolicy: RsvpLanguagePolicy = RsvpLanguagePolicy.UNKNOWN, val prose: ProseState? = null, val pairedEmDashInUnit: Boolean = false, val afterPairedEmDash: Boolean = false, @@ -75,10 +75,9 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { val nextToken = input.nextToken val nextWord = input.nextWord val boundaryBefore = input.boundaryBefore - val focalSuppression = input.focalSuppression - val anticipatoryLanding = input.anticipatoryLanding val emDashAside = input.emDashAside - val phraseContour = input.phraseContour + val wordExpressions = input.wordExpressions + val english = input.languagePolicy == RsvpLanguagePolicy.ENGLISH val prose = input.prose val pairedEmDashInUnit = input.pairedEmDashInUnit val afterPairedEmDash = input.afterPairedEmDash @@ -154,35 +153,19 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { ).coerceIn(0.0, 2.0) val dialogueEntryBoost = 1.0 + (DIALOGUE_ENTRY_BOOST * expressionStrength) - // Phrase-arc shaping at grammatical boundaries: single-word, punctuation-free frames only, - // mirroring the breath in transitionHoldMs (punctuation frames are shaped by the contour - // machinery instead). - val phraseShapeMultiplier = - if (config.useProsodyPacing && - words.size == 1 && - firstWord != null && - frameTokens.none { it.type == TokenType.PUNCTUATION } - ) { - phraseBoundaryShapeMultiplier( - word = firstWord, + val speakerTagMultiplier = + if (english) { + speakerTagMultiplier( + wordsInFrame = words, prevWord = prevWord, nextWord = nextWord, - boundaryBefore = boundaryForBoost, - speedStrength = expressionStrength, - prosodyStrength = prosodyStrength, + config = config, + speedStrength = speedStrength, + explicitSpeakerTag = explicitSpeakerTag, ) } else { 1.0 } - val speakerTagMultiplier = - speakerTagMultiplier( - wordsInFrame = words, - prevWord = prevWord, - nextWord = nextWord, - config = config, - speedStrength = speedStrength, - explicitSpeakerTag = explicitSpeakerTag, - ) } private data class FrameWordTiming( @@ -259,6 +242,7 @@ private fun wordDurationContribution( ): Double { val config = context.config val demand = context.readingDemands.getOrNull(state.wordOrdinal) + val wordExpression = context.wordExpressions.getOrNull(state.wordOrdinal) ?: RsvpWordExpression() val thoughtCue = context.thoughtCues.getOrNull(state.wordOrdinal++) val protectedEmphasis = config.useProsodyPacing && thoughtCue?.protectedEmphasis == true val inAside = config.useParentheticalAside && (state.parentheticalDepth > 0 || context.emDashAside) @@ -279,25 +263,17 @@ private fun wordDurationContribution( else -> 1.0 } if (state.parentheticalDepth > 0 && !inAside) state.sawParentheticalWord = true - val nextWordText = + val nextWord = context.frameTokens .subList(index + 1, context.frameTokens.size) .firstOrNull { it.type == TokenType.WORD } - ?.text - ?: context.nextWord?.text - val previousWordText = + ?: context.nextWord + val previousWord = context.frameTokens .subList(0, index) .lastOrNull { it.type == TokenType.WORD } - ?.text - ?: context.prevWord?.text - val clauseMultiplier = - if (config.useClausePausing) { - val raw = ClauseDetector.getClausePauseFactor(token.text, nextWordText) - 1.0 + ((raw - 1.0) * context.speedStrength * context.clauseConfigStrength) - } else { - 1.0 - } + ?: context.prevWord + val clauseMultiplier = contextualClauseMultiplier(context, token, nextWord) val terminalMultiplier = terminalWordMultiplier( wordIndex = index, @@ -312,12 +288,12 @@ private fun wordDurationContribution( val prosody = prosodyMultiplier( token, - previousWordText, - nextWordText, + previousWord?.text, + nextWord?.text, index == context.firstWordIndex, - context.boundaryBefore, + if (index == context.firstWordIndex) context.boundaryBefore else BoundaryBefore.NONE, context.expressionStrength, - context.prosodyStrength, + if (context.english) context.prosodyStrength else 0.0, ) val dialogueEntry = if (config.useDialogueDetection && @@ -343,10 +319,10 @@ private fun wordDurationContribution( emphasis, if (protectedEmphasis) max(1.0, prosody) else prosody, dialogueEntry, - if (protectedEmphasis) 1.0 else context.focalSuppression, - context.anticipatoryLanding, - if (config.useProsodyPacing) phraseContourMultiplier(context.phraseContour, context.expressionStrength) else 1.0, - context.phraseShapeMultiplier, + if (protectedEmphasis) 1.0 else wordExpression.focalSuppression, + wordExpression.anticipatoryLanding, + if (config.useProsodyPacing) phraseContourMultiplier(wordExpression.phraseContour, context.expressionStrength) else 1.0, + wordPhraseShape(context, index, token, previousWord, nextWord), if (protectedEmphasis) 1.0 + (THOUGHT_EMPHASIS * context.prosodyStrength) else 1.0, ) val baseline = duration @@ -354,6 +330,38 @@ private fun wordDurationContribution( return baseline } +private fun contextualClauseMultiplier(context: RsvpUnitTimingContext, token: Token, nextWord: Token?): Double { + if (!context.config.useClausePausing || !context.english) return 1.0 + val extra = when { + token.isClauseBoundary -> CLAUSE_ONSET_EXTRA + nextWord?.isClauseBoundary == true -> CLAUSE_TAIL_EXTRA + nextWord != null && ClauseDetector.isPhraseEnder(token.text) -> PHRASE_END_EXTRA + else -> 0.0 + } + return 1.0 + (extra * context.speedStrength * context.clauseConfigStrength) +} + +private fun wordPhraseShape( + context: RsvpUnitTimingContext, + index: Int, + token: Token, + previousWord: Token?, + nextWord: Token?, +): Double { + if (!context.config.useProsodyPacing || !context.english) return 1.0 + val before = context.frameTokens.getOrNull(index - 1) ?: context.prevToken + val after = context.frameTokens.getOrNull(index + 1) ?: context.nextToken + if (before?.type == TokenType.PUNCTUATION || after?.type == TokenType.PUNCTUATION) return 1.0 + return phraseBoundaryShapeMultiplier( + word = token, + prevWord = previousWord, + nextWord = nextWord, + boundaryBefore = if (index == context.firstWordIndex) context.boundaryForBoost else BoundaryBefore.NONE, + speedStrength = context.expressionStrength, + prosodyStrength = context.prosodyStrength, + ) +} + /** Bound overlapping automatic cues while leaving explicit difficulty and pause settings intact. */ internal fun coordinateRsvpExpression(vararg multipliers: Double): Double = (1.0 + multipliers.sumOf { it - 1.0 }).coerceIn(MIN_EXPRESSION, MAX_EXPRESSION) @@ -361,6 +369,9 @@ internal fun coordinateRsvpExpression(vararg multipliers: Double): Double = private const val MIN_EXPRESSION = 0.82 private const val MAX_EXPRESSION = 1.4 private const val THOUGHT_EMPHASIS = 0.08 +private const val CLAUSE_ONSET_EXTRA = 0.18 +private const val CLAUSE_TAIL_EXTRA = 0.12 +private const val PHRASE_END_EXTRA = 0.08 private class FramePunctuationContext(contextBefore: ContextSnapshot) { var parentheticalDepth = contextBefore.parentheticalDepth @@ -469,13 +480,17 @@ internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = val duration = wordTiming.duration val transitionHold = - transitionHoldMs( - frameTokens = frameTokens, - firstWord = firstWord, - nextWord = nextWord, - speedStrength = speedStrength, - prosodyStrength = prosodyStrength, - ) + if (english) { + transitionHoldMs( + frameTokens = frameTokens, + lastWord = words.lastOrNull(), + nextWord = nextWord, + speedStrength = speedStrength, + prosodyStrength = prosodyStrength, + ) + } else { + 0.0 + } // Smooth the per-word beat, so changing frame width cannot consume reading time. // Difficulty, split-word holds and phrase processing bypass this smoother. @@ -546,14 +561,18 @@ internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = val scaled = base * pagePauseScale totalDuration += max(scaled, floor) * pageBreaks } - val adaptiveHold = adaptiveHoldMs( - words = words, - config = config, - speedStrength = speedStrength, - hardBoundary = hardBoundary, - nextWord = nextWord, - clauseConfigStrength = clauseConfigStrength, - ) + val adaptiveHold = if (english) { + adaptiveHoldMs( + words = words, + config = config, + speedStrength = speedStrength, + hardBoundary = hardBoundary, + nextWord = nextWord, + clauseConfigStrength = clauseConfigStrength, + ) + } else { + 0.0 + } totalDuration += adaptiveHold if (paragraphBreaks == 0 && pageBreaks == 0) { if (!hardBoundary && diff --git a/app/src/main/java/com/kairo/reader/core/tokenization/HtmlEmphasisApplier.kt b/app/src/main/java/com/kairo/reader/core/tokenization/HtmlEmphasisApplier.kt new file mode 100644 index 00000000..31b910a9 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/tokenization/HtmlEmphasisApplier.kt @@ -0,0 +1,157 @@ +package com.kairo.reader.core.tokenization + +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.TextNode + +/** Optional source metadata. Never guess an occurrence when HTML and reading text disagree. */ +internal object HtmlEmphasisApplier { + fun apply( + tokens: List, + html: String, + normalizeText: (String) -> String, + tokenizeInlineText: (String) -> List, + ): List { + if (tokens.isEmpty() || !EMPHASIS_TAG.containsMatchIn(html) || html.any { it == START || it == END }) return tokens + val body = Jsoup.parse(html).body() + body.select("script, style, template, [hidden], [aria-hidden=true]").remove() + body.getAllElements().filter { element -> + isPageBreak(element) || + ( + element.isBlock && + element.children().none { it.isBlock } && + ParagraphBreakPatterns.sceneBreak.matches(normalizeText(element.text())) + ) + }.forEach { it.replaceWith(TextNode(" ")) } + if (body.text().any { it == START || it == END }) return tokens + val blockTexts = mutableMapOf() + val accents = body.select("em, strong").filter { isLocalEmphasis(it, blockTexts) } + if (accents.isEmpty()) return tokens + accents.forEach { element -> + element.before(TextNode(START.toString())) + element.after(TextNode(END.toString())) + } + val source = markedText(normalizeText(body.text())) + val visible = tokens.indices.filter { tokens[it].type == TokenType.WORD || tokens[it].type == TokenType.PUNCTUATION } + val sourceTokens = tokenizeInlineText(normalizeText(source.text)).map(::canonicalText) + // Full alignment disambiguates repeated words, nested markup and inline word fragments. + // A mismatch simply drops optional expression; tokens, links and positions stay untouched. + if (visible.map { canonicalText(tokens[it].text) } != sourceTokens) return tokens + val ranges = sourceRanges(source.text, sourceTokens, tokenizeInlineText, normalizeText) ?: return tokens + val tokenRanges = MutableList(tokens.size) { IntRange.EMPTY } + visible.forEachIndexed { index, tokenIndex -> tokenRanges[tokenIndex] = ranges[index] } + val emphasized = emphasizedIndices(tokens, tokenRanges, source.ranges) + return tokens.mapIndexed { index, token -> + if (index in emphasized) token.copy(authorEmphasis = true) else token + } + } + + private fun isPageBreak(element: Element): Boolean = + listOf(element.attr("epub:type"), element.attr("role"), element.className()).any { value -> + value.lowercase().split(Regex("\\s+")).any { it in PAGE_BREAK_MARKERS } + } + + private fun sourceRanges( + text: String, + normalizedTokens: List, + tokenizeInlineText: (String) -> List, + normalizeText: (String) -> String, + ): List? { + var offset = 0 + val parts = tokenizeInlineText(text).map { part -> + val start = text.indexOf(canonicalText(part), offset) + if (start < 0) return null + offset = start + part.length + start until offset + } + var cursor = 0 + val ranges = mutableListOf() + while (cursor < parts.size) { + val start = parts[cursor].first + var end: Int + var candidateTokens: List + // Normalization can join pieces across markup, such as 50 %. + do { + end = parts.getOrNull(cursor++)?.last?.plus(1) ?: return null + val candidate = text.substring(start, end) + candidateTokens = if (candidate == normalizedTokens.getOrNull(ranges.size)) { + listOf(candidate) + } else { + tokenizeInlineText(normalizeText(candidate)).map(::canonicalText) + } + } while (candidateTokens.isEmpty() || + candidateTokens.indices.any { + candidateTokens[it] != normalizedTokens.getOrNull(ranges.size + it) + } + ) + repeat(candidateTokens.size) { ranges += start until end } + } + return ranges.takeIf { it.size == normalizedTokens.size } + } + + private fun emphasizedIndices(tokens: List, ranges: List, accents: List): Set = buildSet { + var cursor = 0 + accents.forEach { accent -> + while (cursor < ranges.size && ranges[cursor].last < accent.first) cursor++ + val covered = mutableListOf() + while (cursor < ranges.size && ranges[cursor].first <= accent.last) { + val range = ranges[cursor] + if (tokens[cursor].type == TokenType.WORD && + !range.isEmpty() && + range.first >= accent.first && + range.last <= accent.last + ) { + covered += cursor + } + cursor++ + } + if (covered.size in 1..MAX_EMPHASIS_WORDS) addAll(covered) + } + } + + private fun isLocalEmphasis(element: Element, blockTexts: MutableMap): Boolean { + val parents = element.parents() + if (parents.any { it.normalName() in EXCLUDED_ANCESTORS }) return false + val text = element.text().trim() + if (text.isEmpty() || text.length > MAX_EMPHASIS_CHARACTERS) return false + val block = parents.firstOrNull { it.isBlock } + return block != null && blockTexts.getOrPut(block) { block.text().trim() } != text + } + + private fun markedText(marked: String): EmphasisText { + val text = StringBuilder(marked.length) + val ranges = mutableListOf() + var start = -1 + marked.forEach { character -> + when (character) { + START -> start = text.length + END -> { + if (start >= 0) ranges += start until text.length + start = -1 + } + else -> text.append(canonical(character)) + } + } + return EmphasisText(text.toString(), ranges) + } + + private fun canonicalText(text: String): String = text.map(::canonical).joinToString("") + + private fun canonical(character: Char): Char = when (character) { + '“', '”' -> '"' + '‘', '’' -> '\'' + else -> character + } + + private data class EmphasisText(val text: String, val ranges: List) + + private const val START = '\uE000' + private const val END = '\uE001' + private const val MAX_EMPHASIS_WORDS = 4 + private const val MAX_EMPHASIS_CHARACTERS = 48 + private val PAGE_BREAK_MARKERS = setOf("pagebreak", "doc-pagebreak", "page-break") + private val EMPHASIS_TAG = Regex("<\\s*(em|strong)\\b", RegexOption.IGNORE_CASE) + private val EXCLUDED_ANCESTORS = setOf("em", "strong", "i", "cite", "code", "pre", "h1", "h2", "h3", "h4", "h5", "h6") +} diff --git a/app/src/main/java/com/kairo/reader/core/tokenization/Tokenizer.kt b/app/src/main/java/com/kairo/reader/core/tokenization/Tokenizer.kt index 6877da6c..6ec95aa7 100644 --- a/app/src/main/java/com/kairo/reader/core/tokenization/Tokenizer.kt +++ b/app/src/main/java/com/kairo/reader/core/tokenization/Tokenizer.kt @@ -58,7 +58,7 @@ class Tokenizer { val nextParagraph = paragraphs.getOrNull(index + 1) val nextIsPageBreak = nextParagraph?.let(::isPageBreakParagraph) == true if (index < paragraphs.lastIndex && !isPageBreak && !nextIsPageBreak) { - val currentCue = blockCues.getOrNull(index) ?: BlockCue(BlockType.PARAGRAPH, false) + val currentCue = blockCues.getOrNull(index) ?: BlockCue(BlockType.PARAGRAPH) val nextCue = blockCues.getOrNull(index + 1) val extraPause = structuralPauseMs(currentCue, nextCue) tokens += @@ -71,7 +71,13 @@ class Tokenizer { } // Apply links - try multiple strategies - return applyLinks(tokens.withoutInlinePhysicalPageBreaks().toMutableList(), chapter) + val linked = applyLinks(tokens.withoutInlinePhysicalPageBreaks().toMutableList(), chapter) + return HtmlEmphasisApplier.apply( + linked, + chapter.htmlContent, + normalizeText = { normalizeEpubSymbols(normalizeWhitespace(it)) }, + tokenizeInlineText = ::tokenizeInlineText, + ) } /** @@ -195,8 +201,6 @@ class Tokenizer { BlockType.PARAGRAPH -> Unit } - if (current.hasEmphasis) extra += EMPHASIS_AFTER_MS - when (next?.type) { BlockType.HEADING -> extra += HEADING_BEFORE_MS BlockType.BLOCKQUOTE -> extra += BLOCKQUOTE_BEFORE_MS @@ -217,18 +221,12 @@ class Tokenizer { val tagRegex = Regex("<\\s*(h[1-6]|li|blockquote|pre|p|div|br)\\b[^>]*>", RegexOption.IGNORE_CASE) - val emphasisRegex = Regex("<\\s*(em|i)\\b", RegexOption.IGNORE_CASE) val matches = tagRegex.findAll(cleaned).toList() val cues = mutableListOf() if (matches.isNotEmpty()) { for (i in matches.indices) { val match = matches[i] val tag = match.groupValues[1].lowercase() - val start = match.range.first - val end = if (i < matches.lastIndex) matches[i + 1].range.first else cleaned.length - val content = cleaned.substring(start, end) - val hasEmphasis = emphasisRegex.containsMatchIn(content) - val type = when { tag.startsWith("h") -> BlockType.HEADING @@ -237,7 +235,7 @@ class Tokenizer { tag == "pre" -> BlockType.PREFORMATTED else -> BlockType.PARAGRAPH } - cues += BlockCue(type, hasEmphasis) + cues += BlockCue(type) } } @@ -329,7 +327,6 @@ class Tokenizer { private const val PREFORMATTED_BEFORE_MS = 110L private const val PREFORMATTED_AFTER_MS = 160L private const val LIST_END_AFTER_MS = 120L - private const val EMPHASIS_AFTER_MS = 60L // All punctuation characters we want to handle (NOT including apostrophes used in contractions) private val PUNCTUATION = @@ -410,5 +407,5 @@ class Tokenizer { PREFORMATTED, } - private data class BlockCue(val type: BlockType, val hasEmphasis: Boolean,) + private data class BlockCue(val type: BlockType,) } diff --git a/app/src/main/java/com/kairo/reader/core/tokenization/cjk/CjkTokenizer.kt b/app/src/main/java/com/kairo/reader/core/tokenization/cjk/CjkTokenizer.kt index c0856d2e..e96b75e4 100644 --- a/app/src/main/java/com/kairo/reader/core/tokenization/cjk/CjkTokenizer.kt +++ b/app/src/main/java/com/kairo/reader/core/tokenization/cjk/CjkTokenizer.kt @@ -4,6 +4,7 @@ import com.kairo.reader.core.model.Chapter import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.withoutInlinePhysicalPageBreaks import com.kairo.reader.core.tokenization.ChapterTokenizer +import com.kairo.reader.core.tokenization.HtmlEmphasisApplier class CjkTokenizer(config: CjkSegmenterConfig = CjkSegmenterConfig(),) : ChapterTokenizer { private val segmenter = CjkSegmenter(config) @@ -41,10 +42,11 @@ class CjkTokenizer(config: CjkSegmenterConfig = CjkSegmenterConfig(),) : Chapter } } - return CjkLinkApplier.apply( + val linked = CjkLinkApplier.apply( tokens.withoutInlinePhysicalPageBreaks().toMutableList(), chapter, segmenter::tokenizeInlineText, ) + return HtmlEmphasisApplier.apply(linked, chapter.htmlContent, CjkTextNormalizer::normalizeInlineText, segmenter::tokenizeInlineText) } } diff --git a/app/src/main/java/com/kairo/reader/core/tokenization/rtl/RtlTokenizer.kt b/app/src/main/java/com/kairo/reader/core/tokenization/rtl/RtlTokenizer.kt index 0427c532..9bc6d15d 100644 --- a/app/src/main/java/com/kairo/reader/core/tokenization/rtl/RtlTokenizer.kt +++ b/app/src/main/java/com/kairo/reader/core/tokenization/rtl/RtlTokenizer.kt @@ -4,6 +4,7 @@ import com.kairo.reader.core.model.Chapter import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.withoutInlinePhysicalPageBreaks import com.kairo.reader.core.tokenization.ChapterTokenizer +import com.kairo.reader.core.tokenization.HtmlEmphasisApplier class RtlTokenizer(config: RtlSegmenterConfig = RtlSegmenterConfig(),) : ChapterTokenizer { private val segmenter = RtlSegmenter(config) @@ -41,10 +42,11 @@ class RtlTokenizer(config: RtlSegmenterConfig = RtlSegmenterConfig(),) : Chapter } } - return RtlLinkApplier.apply( + val linked = RtlLinkApplier.apply( tokens.withoutInlinePhysicalPageBreaks().toMutableList(), chapter, segmenter::tokenizeInlineText, ) + return HtmlEmphasisApplier.apply(linked, chapter.htmlContent, RtlTextNormalizer::normalizeInlineText, segmenter::tokenizeInlineText) } } diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/ReaderRoute.kt b/app/src/main/java/com/kairo/reader/ui/navigation/ReaderRoute.kt index 411ba90e..afed3f9c 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/ReaderRoute.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/ReaderRoute.kt @@ -371,6 +371,8 @@ private fun PrefetchReaderFrames( bookId: String, ) { LaunchedEffect( + hasInitialized, + bookId, uiState.chapterIndex, uiState.chapterData, uiState.focusIndex, diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpContextContentResolver.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpContextContentResolver.kt index 2fda2b42..02c80ecb 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpContextContentResolver.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpContextContentResolver.kt @@ -12,14 +12,18 @@ import com.kairo.reader.core.model.RsvpFrame import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType import com.kairo.reader.core.model.shouldInsertSpaceBeforeToken +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import com.kairo.reader.core.rsvp.analysis.RsvpPhraseBoundaries @Composable internal fun rememberRsvpReadingContext(context: RsvpUiContext, frame: RsvpFrame): RsvpReadingContext? { - val tokens = context.state.book.tokens + val book = context.state.book + val tokens = book.tokens + val language = book.generationOptions.languagePolicy val mode = context.state.profile.config.contextAssistMode val color = MaterialTheme.colorScheme.onBackground - return remember(tokens, frame, mode, color) { - if (mode == RsvpContextAssistMode.OFF) null else buildRsvpReadingContext(tokens, frame, color, mode) + return remember(tokens, frame, mode, color, language) { + if (mode == RsvpContextAssistMode.OFF) null else buildRsvpReadingContext(tokens, frame, color, mode, language) } } @@ -29,6 +33,7 @@ internal fun buildRsvpReadingContext( frame: RsvpFrame, color: Color, mode: RsvpContextAssistMode = RsvpContextAssistMode.PREVIOUS_WORDS, + language: RsvpLanguagePolicy? = null, ): RsvpReadingContext? { if (mode == RsvpContextAssistMode.OFF || tokens.isEmpty() || frame.tokens.none { it.type == TokenType.WORD }) return null val fallback = resolveRsvpContextWindow( @@ -36,6 +41,7 @@ internal fun buildRsvpReadingContext( frame.displayOriginalStartIndex, frame.displayOriginalEndExclusive, if (mode == RsvpContextAssistMode.SENTENCE_TICKER) mode else RsvpContextAssistMode.FULL_CLAUSE, + language, ) ?: return null val phraseStart = if (mode != RsvpContextAssistMode.PREVIOUS_WORDS) { fallback.startIndex @@ -87,6 +93,7 @@ internal fun resolveRsvpContextWindow( frameStartIndex: Int, frameEndExclusive: Int, mode: RsvpContextAssistMode, + language: RsvpLanguagePolicy? = null, ): RsvpContextWindow? { if (tokens.isEmpty() || mode == RsvpContextAssistMode.OFF) return null val safeStart = frameStartIndex.coerceIn(0, tokens.lastIndex) @@ -95,6 +102,11 @@ internal fun resolveRsvpContextWindow( ?: findWordAtOrAfter(tokens, safeStart) if (focusWordIndex < 0) return null + // Resolve only the nearby candidates; never copy/analyze the whole chapter on the UI thread. + val isClauseBoundary: (Int) -> Boolean = { index -> + language?.let { RsvpPhraseBoundaries.isBoundary(tokens, index, it) } ?: tokens[index].isClauseBoundary + } + val baseRange = when (mode) { RsvpContextAssistMode.OFF -> return null @@ -104,11 +116,13 @@ internal fun resolveRsvpContextWindow( focusIndex = focusWordIndex, wordsBefore = CONTEXT_PREVIOUS_WORDS, wordsAfter = CONTEXT_UPCOMING_WORDS, + isClauseBoundary = isClauseBoundary, ) RsvpContextAssistMode.FULL_CLAUSE -> resolveClauseRange( tokens = tokens, focusIndex = focusWordIndex, + isClauseBoundary = isClauseBoundary, ) RsvpContextAssistMode.SENTENCE_TICKER -> resolveTickerRange( @@ -138,6 +152,7 @@ private fun resolveNearbyWordRange( focusIndex: Int, wordsBefore: Int, wordsAfter: Int, + isClauseBoundary: (Int) -> Boolean, ): IntRange { var start = focusIndex var seenBefore = 0 @@ -147,7 +162,7 @@ private fun resolveNearbyWordRange( start -= 1 if (candidate.type == TokenType.WORD) { seenBefore += 1 - if (candidate.isClauseBoundary) break + if (isClauseBoundary(start)) break } } @@ -156,7 +171,7 @@ private fun resolveNearbyWordRange( while (endExclusive < tokens.size && seenAfter < wordsAfter) { val candidate = tokens[endExclusive] if (candidate.isParagraphBoundary()) break - if (candidate.type == TokenType.WORD && candidate.isClauseBoundary) break + if (candidate.type == TokenType.WORD && isClauseBoundary(endExclusive)) break endExclusive += 1 if (candidate.isClausePunctuation()) break if (candidate.type == TokenType.WORD) seenAfter += 1 @@ -167,20 +182,21 @@ private fun resolveNearbyWordRange( private fun resolveClauseRange( tokens: List, focusIndex: Int, + isClauseBoundary: (Int) -> Boolean, ): IntRange { var start = focusIndex - while (start > 0 && !tokens[start].isClauseBoundary) { + while (start > 0 && !isClauseBoundary(start)) { val candidate = tokens[start - 1] if (candidate.isParagraphBoundary() || candidate.isClausePunctuation()) break start -= 1 - if (candidate.type == TokenType.WORD && candidate.isClauseBoundary) break + if (candidate.type == TokenType.WORD && isClauseBoundary(start)) break } var endExclusive = (focusIndex + 1).coerceAtMost(tokens.size) while (endExclusive < tokens.size) { val candidate = tokens[endExclusive] if (candidate.isParagraphBoundary()) break - if (candidate.type == TokenType.WORD && candidate.isClauseBoundary) break + if (candidate.type == TokenType.WORD && isClauseBoundary(endExclusive)) break endExclusive += 1 if (candidate.isClausePunctuation()) break } @@ -194,6 +210,7 @@ private fun resolveClauseRange( focusIndex = focusIndex, wordsBefore = CONTEXT_LONG_CLAUSE_PREVIOUS_WORDS, wordsAfter = CONTEXT_LONG_CLAUSE_UPCOMING_WORDS, + isClauseBoundary = isClauseBoundary, ) } } diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpFramePreparation.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpFramePreparation.kt new file mode 100644 index 00000000..bff173c3 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpFramePreparation.kt @@ -0,0 +1,126 @@ +package com.kairo.reader.ui.rsvp + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.data.rsvp.RsvpFrameRepository +import com.kairo.reader.data.rsvp.RsvpFrameSet +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.selects.select + +internal const val STARTUP_PREVIEW_TOKEN_COUNT = 64 + +internal suspend fun CoroutineScope.loadRsvpOpeningFrames( + frameRepository: RsvpFrameRepository, + book: RsvpBookContext, + config: RsvpConfig, + startIndex: Int, + chapterFrames: Deferred, +): RsvpFrameSet? { + if (chapterFrames.isCompleted) return null + val previewConfig = + if (book.tokens.size - startIndex <= STARTUP_PREVIEW_TOKEN_COUNT) { + config + } else { + config.copy(endDelayMs = 0L, rampDownFrames = 0) + } + val openingFrames = async { + runCatching { + frameRepository.getPreviewFrames( + tokens = book.tokens, + startIndex = startIndex, + config = previewConfig, + maxTokenCount = STARTUP_PREVIEW_TOKEN_COUNT, + options = book.generationOptions, + ) + }.onFailure { error -> + if (error is CancellationException) throw error + }.getOrNull() + } + return select { + chapterFrames.onAwait { + openingFrames.cancel() + null + } + openingFrames.onAwait { it } + } +} + +internal suspend fun awaitRsvpChapterHandoff( + chapterFrames: RsvpFrameSet?, + preview: RsvpFrameSet?, + runtime: RsvpRuntimeState, + book: RsvpBookContext, + config: RsvpConfig, + frameRepository: RsvpFrameRepository, + publishChapter: (RsvpFrameSet) -> Unit = {}, +): RsvpFrameSet? { + if (chapterFrames == null || preview == null) return chapterFrames + var activated by mutableStateOf(false) + runtime.activatePreparedChapter = { + if (!activated) { + val scrubStart = preview.frames.getOrNull(runtime.dragStartFrameIndex).takeIf { runtime.isScrubbing } + syncRuntimePositionFromVisibleFrame(runtime, preview.frames, book) + alignRuntimeToFrameSet(runtime, chapterFrames, book) + if (scrubStart != null) { + runtime.dragStartFrameIndex = alignFrameIndex( + chapterFrames.frames, + scrubStart.originalTokenIndex, + scrubStart.resumeCursor, + chapterFrames.frameIndexMap, + ) + } + publishChapter(chapterFrames) + activated = true + } + chapterFrames + } + try { + // Navigation publishes synchronously; automatic handoff waits for a safe playback boundary. + snapshotFlow { + activated || + ( + !runtime.isScrubbing && + !runtime.isAdjustingPosition && + (!runtime.isPlaying || hasConsumedLoadedFrames(runtime, preview.frames)) + ) + }.first { it } + if (activated || runtime.completed) return chapterFrames + val continuingPlayback = hasConsumedLoadedFrames(runtime, preview.frames) + if (!continuingPlayback) syncRuntimePositionFromVisibleFrame(runtime, preview.frames, book) + val prepared = runCatching { + frameRepository.getSeekableFrames( + book.bookId, + book.chapterIndex, + if (continuingPlayback) config.copy(startDelayMs = 0L, rampUpFrames = 0) else config, + startIndex = runtime.currentTokenIndex, + options = book.generationOptions, + ) + }.onFailure { error -> + if (error is CancellationException) throw error + }.getOrNull() + return if (activated) chapterFrames else prepared + } finally { + runtime.activatePreparedChapter = null + } +} + +internal fun prepareRsvpNavigation(context: RsvpUiContext): RsvpUiContext { + val chapter = context.runtime.activatePreparedChapter?.invoke() ?: return context + return context.copy( + frameState = context.frameState.copy( + frames = chapter.frames, + baseTempoMs = chapter.baseTempoMs, + isLoading = false, + isComplete = true, + frameIndexMap = chapter.frameIndexMap, + initialRampStartFrameIndex = chapter.initialRampStartFrameIndex, + ), + ) +} diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreen.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreen.kt index 2233ada2..1aeea5ca 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreen.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreen.kt @@ -2,6 +2,7 @@ package com.kairo.reader.ui.rsvp +import android.os.SystemClock import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -32,6 +33,7 @@ import com.kairo.reader.data.rsvp.RsvpFrameSet import com.kairo.reader.ui.tutorial.StartingTutorialOverlayState import com.kairo.reader.ui.tutorial.StartingTutorialTargetIds import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async import kotlinx.coroutines.delay @Composable @@ -73,8 +75,8 @@ fun RsvpScreen( book = state.book, profile = state.profile, frameRepository = dependencies.frameRepository, - previewStartIndex = runtime.currentTokenIndex, - previewResumeCursor = runtime.currentResumeCursor, + runtime = runtime, + presentationMode = presentationMode, ) val tempoScale = rememberTempoScale( @@ -145,10 +147,7 @@ fun RsvpScreen( autoPlay = state.initialIsPlaying && !isTutorialMode, playbackEnabled = !isTutorialMode && - isFrameSetReadyForPlayback( - frameState = frameState, - presentationMode = presentationMode, - ), + isFrameSetReadyForPlayback(frameState, presentationMode), ) RsvpIndicatorEffects(runtime) @@ -355,12 +354,11 @@ private fun rememberFrameLoadState( book: RsvpBookContext, profile: RsvpProfileContext, frameRepository: RsvpFrameRepository, - previewStartIndex: Int, - previewResumeCursor: Int, + runtime: RsvpRuntimeState, + presentationMode: ReadingPresentationMode, ): RsvpFrameLoadState { val loadConfigKey = remember(profile.config) { frameLoadConfigKey(profile.config) } val instantFrameTokenCount = book.tokens.size - val latestPreviewStartIndex by rememberUpdatedState(previewStartIndex) var frameSetState by remember( book.bookId, book.chapterIndex, @@ -372,8 +370,8 @@ private fun rememberFrameLoadState( mutableStateOf( buildInstantFrameSet( book.copy( - startIndex = previewStartIndex, - startResumeCursor = previewResumeCursor, + startIndex = runtime.currentTokenIndex, + startResumeCursor = runtime.currentResumeCursor, ), profile.config, )?.let { frameSet -> LoadedRsvpFrameSet(frameSet = frameSet, isComplete = false) }, @@ -416,10 +414,19 @@ private fun rememberFrameLoadState( instantFrameTokenCount, loadAttempt, ) { + val preparationStartedAtMs = + if (presentationMode == ReadingPresentationMode.RSVP && + runtime.isPlaying && + frameSetState?.frameSet?.frames?.getOrNull(runtime.frameIndex) != null + ) { + SystemClock.elapsedRealtime() + } else { + null + } val loadStartIndex = resolveFrameLoadStartIndex( bookStartIndex = book.startIndex, - previewStartIndex = latestPreviewStartIndex, + previewStartIndex = runtime.currentTokenIndex, tokenCount = instantFrameTokenCount, ) val hadFrames = frameSetState?.frameSet?.frames?.isNotEmpty() == true @@ -427,26 +434,7 @@ private fun rememberFrameLoadState( frameSetState?.isComplete == true && frameSetState?.frameSet?.frames?.isNotEmpty() == true if (!hadFrames) isFramesLoading = true - if (!hadFrames && book.tokens.isNotEmpty()) { - val preview = - runCatching { - frameRepository.getPreviewFrames( - tokens = book.tokens, - startIndex = loadStartIndex, - config = profile.config, - options = book.generationOptions, - ) - }.onFailure { error -> - if (error is CancellationException) { - throw error - } - }.getOrNull() - if (preview?.frames?.isNotEmpty() == true) { - frameSetState = LoadedRsvpFrameSet(frameSet = preview, isComplete = false) - } - } - - val computed = + val chapterFrames = async { runCatching { frameRepository.getSeekableFrames( book.bookId, @@ -456,10 +444,39 @@ private fun rememberFrameLoadState( options = book.generationOptions, ) }.onFailure { error -> - if (error is CancellationException) { - throw error - } + if (error is CancellationException) throw error }.getOrNull() + } + var preparedPreview = frameSetState?.takeIf { !it.isComplete && !isFramesLoading }?.frameSet + if (isFramesLoading && + runtime.isPlaying && + presentationMode == ReadingPresentationMode.RSVP && + book.tokens.size > STARTUP_PREVIEW_TOKEN_COUNT + ) { + val preview = loadRsvpOpeningFrames(frameRepository, book, profile.config, loadStartIndex, chapterFrames) + if (!chapterFrames.isCompleted && preview?.frames?.isNotEmpty() == true) { + alignRuntimeToFrameSet(runtime, preview, book) + runtime.startupPreparationMs = preparationStartedAtMs?.let { + (SystemClock.elapsedRealtime() - it).coerceAtLeast(0L) + } ?: 0L + preparedPreview = preview + frameSetState = LoadedRsvpFrameSet( + frameSet = preview, + isComplete = preview.frames.last().nextOriginalTokenIndex >= book.tokens.size, + ) + isFramesLoading = false + } + } + + val computed = awaitRsvpChapterHandoff( + chapterFrames = chapterFrames.await(), + preview = preparedPreview, + runtime = runtime, + book = book, + config = profile.config, + frameRepository = frameRepository, + publishChapter = { frameSetState = LoadedRsvpFrameSet(it, isComplete = true) }, + ) if (computed == null) { if (loadAttempt < MAX_FRAME_LOAD_RETRIES) { @@ -475,6 +492,15 @@ private fun rememberFrameLoadState( return@LaunchedEffect } + if (frameSetState?.frameSet === computed) return@LaunchedEffect + // Publish the chapter and its aligned position together, before rendering or playback. + alignRuntimeToFrameSet(runtime, computed, book) + runtime.startupPreparationMs = + if (preparedPreview == null && runtime.isPlaying && preparationStartedAtMs != null) { + (SystemClock.elapsedRealtime() - preparationStartedAtMs).coerceAtLeast(0L) + } else { + 0L + } frameSetState = LoadedRsvpFrameSet(frameSet = computed, isComplete = true) frameLoadFailed = false isFramesLoading = false @@ -550,11 +576,9 @@ internal fun shouldShowLoading( frameState.isLoading && (frameState.frames.isEmpty() || presentationMode == ReadingPresentationMode.BIONIC) -internal fun isFrameSetReadyForPlayback( - frameState: RsvpFrameLoadState, - presentationMode: ReadingPresentationMode, -): Boolean = - presentationMode == ReadingPresentationMode.RSVP || frameState.isComplete +internal fun isFrameSetReadyForPlayback(frameState: RsvpFrameLoadState, presentationMode: ReadingPresentationMode): Boolean = + shouldSyncPositionFromFrameState(frameState) && + (presentationMode == ReadingPresentationMode.RSVP || frameState.isComplete) internal fun buildSessionKey(book: RsvpBookContext): String = "${book.bookId.value}:${book.chapterIndex}:${book.sessionStartIndex}" @@ -625,7 +649,6 @@ private fun RsvpPlaybackEffects( ) { RsvpPositionSaveEffect(context) RsvpLifecyclePositionSaveEffect(context) - RsvpFrameAlignmentEffect(context) RsvpSessionResetEffect(context, sessionKey, autoPlay = autoPlay) RsvpPlaybackLoopEffect(context, enabled = playbackEnabled) } diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt index 8bfa6542..f2dce962 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -29,7 +28,6 @@ internal fun RsvpPositionSaveEffect(context: RsvpUiContext) { val runtime = context.runtime val frames = context.frameState.frames val book = context.state.book - val positionSyncGate = remember { RsvpPositionSyncGate() } LaunchedEffect( runtime.frameIndex, @@ -37,9 +35,7 @@ internal fun RsvpPositionSaveEffect(context: RsvpUiContext) { context.frameState.isComplete, frames, ) { - // A replacement frame list must align to the saved source position before it can - // update that position. Its old numeric frame index belongs to a different list. - if (!positionSyncGate.shouldSync(context.frameState)) return@LaunchedEffect + if (!shouldSyncPositionFromFrameState(context.frameState)) return@LaunchedEffect syncRuntimePositionFromVisibleFrame(runtime, frames, book) val now = SystemClock.elapsedRealtime() val shouldSave = @@ -80,28 +76,8 @@ internal fun RsvpLifecyclePositionSaveEffect(context: RsvpUiContext) { } } -@Composable -internal fun RsvpFrameAlignmentEffect(context: RsvpUiContext) { - val runtime = context.runtime - val frames = context.frameState.frames - val book = context.state.book - - LaunchedEffect(frames, context.frameState.isLoading, context.frameState.isComplete) { - if (!shouldSyncPositionFromFrameState(context.frameState)) return@LaunchedEffect - runtime.frameIndex = - alignFrameIndex( - frames = frames, - tokenIndex = runtime.currentTokenIndex, - resumeCursor = runtime.currentResumeCursor, - frameIndexMap = context.frameState.frameIndexMap, - ) - runtime.rampStartFrameIndex = runtime.frameIndex - syncRuntimePositionFromVisibleFrame(runtime, frames, book) - } -} - internal fun shouldSyncPositionFromFrameState(frameState: RsvpFrameLoadState): Boolean = - frameState.frames.isNotEmpty() && !frameState.isLoading && frameState.isComplete + frameState.frames.isNotEmpty() && !frameState.isLoading && !frameState.loadFailed internal fun syncRuntimePositionFromVisibleFrame( runtime: RsvpRuntimeState, @@ -140,19 +116,6 @@ internal fun RsvpFrameLoadFailureEffect(context: RsvpUiContext) { } } -internal class RsvpPositionSyncGate { - private var lastReadyFrames: List? = null - - fun shouldSync(frameState: RsvpFrameLoadState): Boolean { - if (!shouldSyncPositionFromFrameState(frameState)) return false - if (lastReadyFrames !== frameState.frames) { - lastReadyFrames = frameState.frames - return false - } - return true - } -} - @Composable internal fun RsvpSessionResetEffect( context: RsvpUiContext, @@ -226,6 +189,7 @@ internal fun RsvpPlaybackLoopEffect( LaunchedEffect(enabled, runtime.isPlaying, runtime.frameIndex, runtime.completed, frames) { if (!enabled) return@LaunchedEffect if (!runtime.isPlaying || runtime.completed) return@LaunchedEffect + if (hasConsumedLoadedFrames(runtime, frames)) return@LaunchedEffect if (runtime.frameIndex >= frames.size) return@LaunchedEffect val frame = frames[runtime.frameIndex] val tempoScale = latestTempoScale @@ -268,6 +232,12 @@ internal fun RsvpPlaybackLoopEffect( ((frameMs * rampMultiplier + resumeDelayMs * tempoScale) * runtime.comprehensionPaceScale) .roundToLong() .coerceAtLeast(MIN_FRAME_DELAY_MS) + if (runtime.frameIndex == context.frameState.initialRampStartFrameIndex) { + // Preparation overlaps only the startup pause, never the word's reading time. + val startDelayMs = (config.startDelayMs * tempoScale * runtime.comprehensionPaceScale) + .roundToLong().coerceAtLeast(0L) + scaledMs -= runtime.startupPreparationMs.coerceIn(0L, startDelayMs) + } val floorMs = frameFloorMs(frame, config, effectiveTempoMs) if (scaledMs < floorMs) { scaledMs = floorMs @@ -284,6 +254,7 @@ internal fun RsvpPlaybackLoopEffect( } else { delay(delayMs) } + runtime.startupPreparationMs = 0L context.callbacks.playback.onFrameConsumed(frame) if (runtime.frameIndex == frames.lastIndex) { if (shouldCompleteAtLoadedFrameBoundary(context)) { @@ -327,6 +298,12 @@ internal fun holdAtLoadingFrameBoundary(context: RsvpUiContext) { runtime.nextFrameAtMs = 0L } +internal fun hasConsumedLoadedFrames(runtime: RsvpRuntimeState, frames: List): Boolean = + frames.isNotEmpty() && + runtime.frameIndex == frames.lastIndex && + runtime.scheduledFrameIndex == -1 && + runtime.currentTokenIndex >= frames.last().nextOriginalTokenIndex + @Composable internal fun RsvpAutoHideControlsEffect(runtime: RsvpRuntimeState) { LaunchedEffect(runtime.showControls, runtime.isPlaying) { diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt index c14f8212..c5b32115 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt @@ -135,7 +135,8 @@ internal fun addBookmarkNow(context: RsvpUiContext) { runtime.showQuickSettings = false } -internal fun replayPreviousPhrase(context: RsvpUiContext) { +internal fun replayPreviousPhrase(initialContext: RsvpUiContext) { + val context = prepareRsvpNavigation(initialContext) val runtime = context.runtime val tokens = context.state.book.tokens val frames = context.frameState.frames @@ -314,7 +315,7 @@ private fun handleDrag( } runtime.dragAxis = if (absX > absY) { - startScrubbing(context) + startScrubbing(prepareRsvpNavigation(context)) RsvpDragAxis.HORIZONTAL } else { RsvpDragAxis.VERTICAL @@ -322,7 +323,7 @@ private fun handleDrag( } when (runtime.dragAxis) { - RsvpDragAxis.HORIZONTAL -> handleSweep(context) + RsvpDragAxis.HORIZONTAL -> handleSweep(prepareRsvpNavigation(context)) RsvpDragAxis.VERTICAL -> handleTempoDrag(context) RsvpDragAxis.NONE -> Unit } diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenPlayback.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenPlayback.kt index 50b5dd08..cfdac3ce 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenPlayback.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenPlayback.kt @@ -3,6 +3,24 @@ package com.kairo.reader.ui.rsvp import com.kairo.reader.core.model.RsvpFrame import com.kairo.reader.core.model.nearestWordIndex import com.kairo.reader.data.rsvp.RsvpFrameIndexMap +import com.kairo.reader.data.rsvp.RsvpFrameSet + +internal fun alignRuntimeToFrameSet( + runtime: RsvpRuntimeState, + frameSet: RsvpFrameSet, + book: RsvpBookContext, +) { + if (frameSet.frames.isEmpty()) return + runtime.frameIndex = + alignFrameIndex( + frames = frameSet.frames, + tokenIndex = runtime.currentTokenIndex, + resumeCursor = runtime.currentResumeCursor, + frameIndexMap = frameSet.frameIndexMap, + ) + runtime.rampStartFrameIndex = runtime.frameIndex + syncRuntimePositionFromVisibleFrame(runtime, frameSet.frames, book) +} internal fun resolveCurrentTokenIndex( frames: List, diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt index 040aa751..68086075 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt @@ -11,6 +11,7 @@ import com.kairo.reader.core.model.BionicReadingPreferences import com.kairo.reader.core.model.RsvpFrame import com.kairo.reader.core.rsvp.timing.RsvpSessionTimingPolicy import com.kairo.reader.data.rsvp.RsvpFrameIndexMap +import com.kairo.reader.data.rsvp.RsvpFrameSet internal data class RsvpFrameLoadState( val frames: List, @@ -68,6 +69,8 @@ internal class RsvpRuntimeState( var rampStartFrameIndex by mutableIntStateOf(0) var scheduledFrameIndex by mutableIntStateOf(-1) var nextFrameAtMs by mutableLongStateOf(0L) + var startupPreparationMs = 0L + var activatePreparedChapter: (() -> RsvpFrameSet)? = null private var playbackIsPlaying by mutableStateOf(true) var isPlaying: Boolean get() = playbackIsPlaying @@ -81,6 +84,7 @@ internal class RsvpRuntimeState( } ?: 1.0 pausedAtMs = null } else { + startupPreparationMs = 0L pausedAtMs = monotonicTimeMs() } playbackIsPlaying = value diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpContourTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpContourTest.kt index d3c29f74..2dcd0c12 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpContourTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpContourTest.kt @@ -202,21 +202,19 @@ class ComprehensionRsvpContourTest : ComprehensionRsvpTestBase() { ) val withoutClause = - engine - .generateFrames( - tokens = listOf(w("because")), - startIndex = 0, - config = baseConfig.copy(useClausePausing = false) - ) + englishFrames( + tokens = listOf(w("because")), + startIndex = 0, + config = baseConfig.copy(useClausePausing = false) + ) .first() .durationMs val withClause = - engine - .generateFrames( - tokens = listOf(w("because")), - startIndex = 0, - config = baseConfig.copy(useClausePausing = true) - ) + englishFrames( + tokens = listOf(w("because")), + startIndex = 0, + config = baseConfig.copy(useClausePausing = true) + ) .first() .durationMs diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpEngineTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpEngineTest.kt index 40bef14d..3cf67547 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpEngineTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpEngineTest.kt @@ -10,6 +10,9 @@ import org.junit.Test class ComprehensionRsvpEngineTest { private val engine = ComprehensionRsvpEngine() + private fun englishFrames(tokens: List, startIndex: Int, config: RsvpConfig) = + engine.generateFrames(tokens, startIndex, config, RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)) + @Test fun longWordsNeverFlashTooFastAtHighWpm() { val config = @@ -376,13 +379,13 @@ class ComprehensionRsvpEngineTest { val fastConfig = baseConfig.copy(tempoMsPerWord = 70L) val slowTag = - engine.generateFrames(tagTokens, 0, slowConfig).first().durationMs + englishFrames(tagTokens, 0, slowConfig).first().durationMs val slowPlain = - engine.generateFrames(plainTokens, 0, slowConfig).first().durationMs + englishFrames(plainTokens, 0, slowConfig).first().durationMs val fastTag = - engine.generateFrames(tagTokens, 0, fastConfig).first().durationMs + englishFrames(tagTokens, 0, fastConfig).first().durationMs val fastPlain = - engine.generateFrames(plainTokens, 0, fastConfig).first().durationMs + englishFrames(plainTokens, 0, fastConfig).first().durationMs val slowSpeedup = slowPlain - slowTag val fastSpeedup = fastPlain - fastTag @@ -499,7 +502,7 @@ class ComprehensionRsvpEngineTest { val tokens = listOf( Token( - text = "blue", + text = "because", type = TokenType.WORD, frequencyScore = 1.0, isClauseBoundary = true, @@ -508,13 +511,13 @@ class ComprehensionRsvpEngineTest { ) val adaptive = - engine.generateFrames( + englishFrames( tokens = tokens, startIndex = 0, config = baseConfig, ) val baseline = - engine.generateFrames( + englishFrames( tokens = tokens, startIndex = 0, config = baseConfig.copy(useAdaptiveTiming = false), diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpProsodyTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpProsodyTest.kt index 7f06d014..38b67622 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpProsodyTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpProsodyTest.kt @@ -27,8 +27,8 @@ class ComprehensionRsvpProsodyTest : ComprehensionRsvpTestBase() { ) val tokens = listOf(w("he"), w("said")) - val withoutDetection = engine.generateFrames(tokens, 0, baseConfig) - val withDetection = engine.generateFrames( + val withoutDetection = englishFrames(tokens, 0, baseConfig) + val withDetection = englishFrames( tokens, 0, baseConfig.copy(useDialogueDetection = true) @@ -62,8 +62,8 @@ class ComprehensionRsvpProsodyTest : ComprehensionRsvpTestBase() { useDialogueDetection = false, ) - val standalone = engine.generateFrames(listOf(w("the")), 0, config).first().durationMs - val bridged = engine.generateFrames(listOf(w("the"), w("mountain")), 0, config) + val standalone = englishFrames(listOf(w("the")), 0, config).first().durationMs + val bridged = englishFrames(listOf(w("the"), w("mountain")), 0, config) assertTrue(bridged.isNotEmpty()) assertTrue( @@ -95,8 +95,8 @@ class ComprehensionRsvpProsodyTest : ComprehensionRsvpTestBase() { useDialogueDetection = false, ) - val neutral = engine.generateFrames(listOf(w("and"), w("ready")), 0, config) - val negation = engine.generateFrames(listOf(w("not"), w("ready")), 0, config) + val neutral = englishFrames(listOf(w("and"), w("ready")), 0, config) + val negation = englishFrames(listOf(w("not"), w("ready")), 0, config) assertTrue(neutral.isNotEmpty() && negation.isNotEmpty()) assertTrue( @@ -128,8 +128,8 @@ class ComprehensionRsvpProsodyTest : ComprehensionRsvpTestBase() { useDialogueDetection = false, ) - val withProsody = engine.generateFrames(listOf(w("the"), w("mountain")), 0, config) - val withoutProsody = engine.generateFrames( + val withProsody = englishFrames(listOf(w("the"), w("mountain")), 0, config) + val withoutProsody = englishFrames( listOf(w("the"), w("mountain")), 0, config.copy(useProsodyPacing = false), @@ -163,12 +163,12 @@ class ComprehensionRsvpProsodyTest : ComprehensionRsvpTestBase() { useProsodyPacing = true, ) - val low = engine.generateFrames( + val low = englishFrames( listOf(w("the"), w("mountain")), 0, config.copy(prosodyStrength = 0.2), ) - val high = engine.generateFrames( + val high = englishFrames( listOf(w("the"), w("mountain")), 0, config.copy(prosodyStrength = 1.6), diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpTestBase.kt b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpTestBase.kt index 01767376..84e4d5b0 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpTestBase.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpTestBase.kt @@ -7,6 +7,9 @@ import com.kairo.reader.core.model.TokenType abstract class ComprehensionRsvpTestBase { protected val engine = ComprehensionRsvpEngine() + protected fun englishFrames(tokens: List, startIndex: Int, config: RsvpConfig) = + engine.generateFrames(tokens, startIndex, config, RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)) + protected fun w(text: String) = Token( text = text, diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpExpressiveReadingTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpExpressiveReadingTest.kt new file mode 100644 index 00000000..ed3b9310 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpExpressiveReadingTest.kt @@ -0,0 +1,107 @@ +package com.kairo.reader.core.rsvp + +import com.kairo.reader.core.model.Chapter +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.analysis.RsvpPhraseBoundaries +import com.kairo.reader.core.rsvp.analysis.analyzeExpandedTokens +import com.kairo.reader.core.rsvp.engine.ExpandedToken +import com.kairo.reader.core.tokenization.Tokenizer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpExpressiveReadingTest : ComprehensionRsvpTestBase() { + private val english = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + + @Test + fun connectedUsesOfAmbiguousWordsStayInsideOneThought() { + for (text in listOf( + "I want that one", + "We met after school", + "I saw the day before yesterday", + "Butter and jam", + "We left before spring" + )) { + val frames = frames(text) + assertEquals(text, 1, frames.mapNotNull { it.phraseStartTokenIndex }.distinct().size) + } + } + + @Test + fun pairedConnectivesBreatheBeforeThePair() { + val tokens = RsvpPhraseBoundaries.annotate(tokenize("We continued even though it rained"), RsvpLanguagePolicy.ENGLISH) + assertTrue(tokens.first { it.text == "even" }.isClauseBoundary) + assertFalse(tokens.first { it.text == "though" }.isClauseBoundary) + } + + @Test + fun dependentClausesShareTheirBoundaryAcrossGroupingWidths() { + for (width in 1..3) { + val config = stableConfig.copy(enablePhraseChunking = width > 1, maxWordsPerUnit = width, maxCharsPerUnit = 32) + for (text in listOf("I waited before opening the door", "I stayed because she asked", "We left after he called")) { + val tokens = tokenize(text) + val frames = engine.generateFrames(tokens, 0, config, english) + assertEquals(text, 2, frames.mapNotNull { it.phraseStartTokenIndex }.distinct().size) + val boundary = tokens.indexOfFirst { it.text in setOf("before", "because", "after") } + assertTrue(frames.none { it.originalTokenIndex < boundary && it.displayOriginalEndExclusive > boundary }) + } + } + } + + @Test + fun unknownAndNonEnglishBooksDoNotAcquireEnglishWordListBoundaries() { + val tokens = tokenize("I stayed because she asked") + for (language in RsvpLanguagePolicy.entries.filter { it != RsvpLanguagePolicy.ENGLISH }) { + assertFalse(RsvpPhraseBoundaries.annotate(tokens, language).any { it.isClauseBoundary }) + val frames = engine.generateFrames(tokens, 0, stableConfig, RsvpGenerationOptions(language)) + assertEquals(language.name, 1, frames.mapNotNull { it.phraseStartTokenIndex }.distinct().size) + } + } + + @Test + fun authorEmphasisChangesOnlyItsOwnExposureAndRespectsTheProsodyToggle() { + val neutral = tokenize("I said Tuesday") + val marked = Tokenizer().tokenize(Chapter(0, null, "

I said Tuesday

", "I said Tuesday")) + val config = stableConfig.copy(useFocalStress = false, useAnticipatoryLanding = false) + val before = engine.generateFrames(neutral, 0, config, english) + val after = engine.generateFrames(marked, 0, config, english) + assertEquals(before.take(2).map { it.durationMs }, after.take(2).map { it.durationMs }) + assertTrue(after.last().durationMs > before.last().durationMs) + val disabled = config.copy(useProsodyPacing = false, useFocalStress = true) + assertEquals( + engine.generateFrames(neutral, 0, disabled, english).map { it.durationMs }, + engine.generateFrames(marked, 0, disabled, english).map { it.durationMs }, + ) + } + + @Test + fun clearPhraseFocusDoesNotDefaultToTheLongestWord() { + val analysis = analyze("She photographed the cat") + assertEquals(setOf(3), analysis.focalWordIndices) + val ambiguous = analyze("Mysterious visitors arrived quietly") + assertEquals(setOf(0, 1, 2, 3), ambiguous.focalWordIndices) + } + + @Test + fun authoredShortWordKeepsFocusAndSplitWordResumeCursorsRemainDistinct() { + val tokens = tokenize("I said representatives").map { it.copy(authorEmphasis = it.text == "I" || it.text == "representatives") } + val config = stableConfig.copy(maxChunkLength = 6) + val frames = engine.generateFrames(tokens, 0, config, english) + val chunks = frames.filter { it.originalTokenIndex == 2 } + assertTrue(chunks.size > 1) + assertEquals(chunks.size, chunks.map { it.resumeCursor }.distinct().size) + assertTrue(chunks.all { frame -> frame.tokens.filter { it.type == TokenType.WORD }.all { it.authorEmphasis } }) + assertEquals(listOf(0, 1, 2), frames.map { it.originalTokenIndex }.distinct()) + } + + private fun tokenize(text: String) = Tokenizer().tokenize(Chapter(0, null, "", text)) + + private fun frames(text: String) = engine.generateFrames(tokenize(text), 0, stableConfig, english) + + private fun analyze(text: String) = analyzeExpandedTokens( + tokenize(text).mapIndexed { index, token -> ExpandedToken(token, index, index, 0, token.text.length) }, + stableConfig, + RsvpLanguagePolicy.ENGLISH, + ) +} diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpGroupedExpressionTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpGroupedExpressionTest.kt new file mode 100644 index 00000000..311f54ee --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpGroupedExpressionTest.kt @@ -0,0 +1,89 @@ +package com.kairo.reader.core.rsvp.timing + +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.ComprehensionRsvpEngine +import com.kairo.reader.core.rsvp.RsvpGenerationOptions +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import com.kairo.reader.core.rsvp.analysis.multiWordPenalty +import com.kairo.reader.core.rsvp.engine.BoundaryBefore +import com.kairo.reader.core.rsvp.engine.ContextSnapshot +import com.kairo.reader.core.rsvp.engine.PhraseContour +import com.kairo.reader.core.rsvp.engine.RhythmState +import com.kairo.reader.core.rsvp.engine.RsvpWordExpression +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpGroupedExpressionTest { + private val config = RsvpConfig( + tempoMsPerWord = 200L, + minWordMs = 1L, + difficultWordSupport = 0.0, + useAdaptiveTiming = false, + useClausePausing = false, + useDialogueDetection = false, + usePunctuationLandingHold = false, + useFocalStress = false, + startDelayMs = 0L, + endDelayMs = 0L, + rampUpFrames = 0, + rampDownFrames = 0, + ) + + @Test + fun anAccentOnTheSecondOrThirdWordIsNeitherLostNorAppliedToEveryWord() { + val accent = RsvpWordExpression(anticipatoryLanding = 1.1) + val singleDelta = duration(listOf(accent)) - duration(listOf(RsvpWordExpression())) + assertTrue(singleDelta > 0) + for (width in 2..3) { + val neutral = List(width) { RsvpWordExpression() } + val baseline = duration(neutral) + val first = duration(neutral.mapIndexed { index, cue -> if (index == 0) accent else cue }) + val last = duration(neutral.mapIndexed { index, cue -> if (index == width - 1) accent else cue }) + assertEquals(first, last) + assertEquals(singleDelta * multiWordPenalty(width), (last - baseline).toDouble(), 2.0) + } + } + + @Test + fun tailContoursBelongToTheirOwnWordAndStillObeyTheProsodyToggle() { + val neutral = List(3) { RsvpWordExpression() } + val shaped = neutral.dropLast(1) + RsvpWordExpression(phraseContour = PhraseContour(0.2, 0.0)) + assertTrue(duration(shaped) > duration(neutral)) + assertEquals(duration(neutral, false), duration(shaped, false)) + } + + @Test + fun engineCarriesALandingThatFallsInsideAGroup() { + val tokens = listOf(word("in"), word("the"), word("room"), Token(".", TokenType.PUNCTUATION)) + val engine = ComprehensionRsvpEngine() + val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + val grouped = config.copy(enablePhraseChunking = true, maxWordsPerUnit = 3, maxCharsPerUnit = 32) + val withLanding = engine.generateFrames(tokens, 0, grouped.copy(useAnticipatoryLanding = true), options) + val withoutLanding = engine.generateFrames(tokens, 0, grouped.copy(useAnticipatoryLanding = false), options) + val index = withLanding.indexOfFirst { it.tokens.any { token -> token.text == "the" } } + val frame = withLanding[index] + assertTrue(frame.tokens.count { it.type == TokenType.WORD } > 1) + assertTrue(frame.originalTokenIndex < 1) + assertTrue(frame.durationMs > withoutLanding[index].durationMs) + } + + private fun duration(expressions: List, prosody: Boolean = true): Long = computeUnitDurationMs( + RsvpUnitTimingInput( + frameTokens = List(expressions.size) { word("oak") }, + config = config.copy(useProsodyPacing = prosody), + contextBefore = ContextSnapshot(0, false), + rhythm = RhythmState(1.0, 1000.0, 1000.0), + prevToken = null, + prevWord = null, + nextToken = null, + nextWord = null, + boundaryBefore = BoundaryBefore.NONE, + wordExpressions = expressions, + ), + ) + + private fun word(text: String) = Token(text, TokenType.WORD) +} diff --git a/app/src/test/java/com/kairo/reader/core/tokenization/HtmlAuthorEmphasisTest.kt b/app/src/test/java/com/kairo/reader/core/tokenization/HtmlAuthorEmphasisTest.kt new file mode 100644 index 00000000..ae4761f0 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/tokenization/HtmlAuthorEmphasisTest.kt @@ -0,0 +1,138 @@ +package com.kairo.reader.core.tokenization + +import com.kairo.reader.core.model.Chapter +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.data.books.EpubContentRewriter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HtmlAuthorEmphasisTest { + @Test + fun repeatedWordsKeepTheCorrectAuthoredOccurrence() { + val tokens = tokenize("

I said Tuesday, not Tuesday again.

", "I said Tuesday, not Tuesday again.") + assertEquals(listOf(false, true), tokens.filter { it.text == "Tuesday" }.map { it.authorEmphasis }) + } + + @Test + fun nestedMarkupEntitiesAndQuotesKeepSourcePositionsAndLinks() { + val html = "

She said "you can" & left.

" + val tokens = tokenize(html, "She said \"you can\" & left.") + val neutral = tokenize(html.replace("", "").replace("", ""), "She said \"you can\" & left.") + assertEquals(listOf("you", "can"), tokens.filter { it.authorEmphasis }.map { it.text }) + assertEquals(neutral, tokens.map { it.copy(authorEmphasis = false) }) + assertTrue(tokens.filter { it.authorEmphasis }.all { it.linkChapterIndex == 3 }) + } + + @Test + fun adjacentMarkupDoesNotManufactureWordsOrAccentPartialWords() { + val tokens = tokenize("

An unbelievable result!

", "An unbelievable result!") + assertEquals(listOf("An", "unbelievable", "result"), tokens.filter { it.type == TokenType.WORD }.map { it.text }) + assertEquals(listOf("result"), tokens.filter { it.authorEmphasis }.map { it.text }) + } + + @Test + fun decorativeWholeBlockAndLongEmphasisStayNeutral() { + val examples = listOf( + "

A foreign word.

" to "A foreign word.", + "

A whole paragraph.

" to "A whole paragraph.", + "

A book title here.

" to "A book title here.", + "

A heading

" to "A heading", + "

A rather long run of five words here.

" to "A rather long run of five words here.", + ) + examples.forEach { (html, plain) -> assertFalse(html, tokenize(html, plain).any { it.authorEmphasis }) } + } + + @Test + fun mismatchedHtmlCannotAccentAnotherOccurrence() { + val tokens = tokenize("

Tuesday and Tuesday.

", "Tuesday.") + assertFalse(tokens.any { it.authorEmphasis }) + assertEquals(listOf("Tuesday", "."), tokens.map { it.text }) + } + + @Test + fun semanticEmphasisWorksAcrossTokenizerFamilies() { + for ((language, plain, accented) in listOf( + Triple("fr", "Je veux ceci.", "ceci"), + Triple("ar", "أنا أريد هذا.", "هذا"), + Triple("zh", "今天晴天。", "晴天"), + )) { + val html = "

${plain.replace(accented, "$accented")}

" + val tokens = tokenize(html, plain, language) + assertTrue(language, tokens.any { it.authorEmphasis }) + assertEquals(language, accented, tokens.filter { it.authorEmphasis }.joinToString("") { it.text }) + } + } + + @Test + fun paragraphBoundariesAndNumberNormalizationDoNotMoveEmphasis() { + val html = "

First.

I meant 50 %, not 20 %.

" + val plain = "First.\n\nI meant 50 %, not 20 %." + val tokens = tokenize(html, plain) + assertEquals(listOf("50%"), tokens.filter { it.authorEmphasis }.map { it.text }) + assertEquals(1, tokens.count { it.type == TokenType.PARAGRAPH_BREAK }) + assertEquals(0L, tokens.first { it.type == TokenType.PARAGRAPH_BREAK }.pauseAfterMs) + } + + @Test + fun manyAccentsRemainOrderedAndDoNotChangeTokenCount() { + val html = "

" + List(500) { "word again. " }.joinToString("") + "

" + val tokens = tokenize(html, List(500) { "word again. " }.joinToString("")) + assertEquals(1500, tokens.size) + assertEquals(500, tokens.count { it.authorEmphasis }) + } + + @Test + fun importedPageMarkersDoNotDiscardEmphasisOrChangeReadingTokens() { + for (attribute in listOf("epub:type='pagebreak'", "role='doc-pagebreak'", "class='page-break'")) { + val html = "

Tuesday came.

12

I meant Tuesday again.

" + for (language in listOf("en", "fr", "ar", "zh")) { + val tokens = tokenizeImportedHtml(html, language) + assertEquals(listOf(false, true), tokens.filter { it.text == "Tuesday" }.map { it.authorEmphasis }) + assertEquals( + tokenizeImportedHtml(html.replace("", "").replace("", ""), language), + tokens.map { it.copy(authorEmphasis = false) }, + ) + } + } + } + + @Test + fun importedSceneSeparatorsDoNotDiscardEmphasis() { + for (separator in listOf("— —", "– –", "* * *", "---", "• • •")) { + val html = "

Tuesday came.

$separator

I meant Tuesday again.

" + val tokens = tokenizeImportedHtml(html) + assertEquals(separator, listOf(false, true), tokens.filter { it.text == "Tuesday" }.map { it.authorEmphasis }) + assertTrue(separator, tokens.any { it.type == TokenType.PAGE_BREAK }) + } + } + + @Test + fun normalizationAcrossEmphasisEdgesKeepsUnrelatedAccents() { + for (number in listOf("50 %", "20 º C", "− 35")) { + val html = "

I meant $number, then Tuesday.

" + val tokens = tokenizeImportedHtml(html) + assertEquals(number, listOf("Tuesday"), tokens.filter { it.authorEmphasis }.map { it.text }) + assertEquals( + tokenizeImportedHtml(html.replace("", "").replace("", "")), + tokens.map { it.copy(authorEmphasis = false) }, + ) + } + } + + @Test + fun numericMarkupKeepsFollowingEmphasisAcrossTokenizerFamilies() { + val html = "

I meant 50 %, then Tuesday.

" + for (language in listOf("en", "fr", "ar", "zh")) { + assertTrue(language, tokenizeImportedHtml(html, language).any { it.text == "Tuesday" && it.authorEmphasis }) + } + } + + private fun tokenizeImportedHtml(html: String, language: String = "en"): List = + tokenize(html, EpubContentRewriter().extractPlainText(html), language) + + private fun tokenize(html: String, plain: String, language: String = "en"): List = + TokenizerRegistry.resolve(language).tokenize(Chapter(0, null, html, plain)) +} diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/BionicPlaybackReadinessTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/BionicPlaybackReadinessTest.kt index 6173aa09..5bcba0f5 100644 --- a/app/src/test/java/com/kairo/reader/ui/rsvp/BionicPlaybackReadinessTest.kt +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/BionicPlaybackReadinessTest.kt @@ -25,11 +25,23 @@ class BionicPlaybackReadinessTest { } @Test - fun rsvpKeepsItsInstantPreviewBehavior() { + fun rsvpShowsInstantPreviewWithoutPlayingIt() { val previewState = frameState(isLoading = true, isComplete = false) assertFalse(shouldShowLoading(previewState, ReadingPresentationMode.RSVP)) - assertTrue(isFrameSetReadyForPlayback(previewState, ReadingPresentationMode.RSVP)) + assertFalse(isFrameSetReadyForPlayback(previewState, ReadingPresentationMode.RSVP)) + } + + @Test + fun playbackWaitsForSuccessfulNonEmptyFrames() { + val ready = frameState(isLoading = false, isComplete = true) + + assertTrue(isFrameSetReadyForPlayback(ready, ReadingPresentationMode.RSVP)) + assertFalse(isFrameSetReadyForPlayback(ready.copy(isLoading = true), ReadingPresentationMode.RSVP)) + assertTrue(isFrameSetReadyForPlayback(ready.copy(isComplete = false), ReadingPresentationMode.RSVP)) + assertFalse(isFrameSetReadyForPlayback(ready.copy(isComplete = false), ReadingPresentationMode.BIONIC)) + assertFalse(isFrameSetReadyForPlayback(ready.copy(loadFailed = true), ReadingPresentationMode.RSVP)) + assertFalse(isFrameSetReadyForPlayback(ready.copy(frames = emptyList()), ReadingPresentationMode.RSVP)) } private fun frameState( diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpFrameAlignmentTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpFrameAlignmentTest.kt index c3a81a7c..a41e9346 100644 --- a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpFrameAlignmentTest.kt +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpFrameAlignmentTest.kt @@ -1,16 +1,45 @@ package com.kairo.reader.ui.rsvp +import com.kairo.reader.core.model.BookId import com.kairo.reader.core.model.RsvpConfig import com.kairo.reader.core.model.RsvpFrame import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType import com.kairo.reader.core.rsvp.ComprehensionRsvpEngine +import com.kairo.reader.data.rsvp.RsvpFrameSet import org.junit.Assert.assertEquals import org.junit.Test class RsvpFrameAlignmentTest { private val engine = ComprehensionRsvpEngine() + @Test + fun replacementFramesAlignToLiveResumeCursorBeforePlayback() { + val frames = listOf(0 to 0, 4 to 40, 4 to 44, 5 to 50).map { (tokenIndex, cursor) -> + RsvpFrame( + tokens = listOf(Token("word", TokenType.WORD)), + durationMs = 120L, + originalTokenIndex = tokenIndex, + resumeCursor = cursor, + ) + } + val book = RsvpBookContext(BookId("book"), 0, emptyList(), startIndex = 1) + for (oldFrameIndex in listOf(0, 10)) { + val runtime = RsvpRuntimeState().apply { + frameIndex = oldFrameIndex + currentTokenIndex = 4 + currentResumeCursor = 44 + } + + alignRuntimeToFrameSet(runtime, RsvpFrameSet(frames, 120L), book) + + assertEquals(2, runtime.frameIndex) + assertEquals(2, runtime.rampStartFrameIndex) + assertEquals(4, runtime.currentTokenIndex) + assertEquals(44, runtime.currentResumeCursor) + } + } + @Test fun alignFrameIndexTargetsFirstSplitForFreshTokenStart() { val config = diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpFramePreparationTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpFramePreparationTest.kt new file mode 100644 index 00000000..418fa7fb --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpFramePreparationTest.kt @@ -0,0 +1,154 @@ +package com.kairo.reader.ui.rsvp + +import androidx.compose.runtime.snapshots.Snapshot +import com.kairo.reader.core.model.BookId +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.RsvpFrame +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.RsvpGenerationOptions +import com.kairo.reader.data.rsvp.RsvpFrameRepository +import com.kairo.reader.data.rsvp.RsvpFrameSet +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RsvpFramePreparationTest { + private val config = RsvpConfig() + private val book = RsvpBookContext(BookId("book"), 0, List(100) { Token("word", TokenType.WORD) }, 0) + private val chapter = RsvpFrameSet(emptyList(), config.tempoMsPerWord) + + @Test + fun cachedChapterDoesNotWaitForOpeningGeneration() = runTest { + val repository = repository { error("Cached chapters must not generate opening frames") } + + assertNull(loadRsvpOpeningFrames(repository, book, config, 0, CompletableDeferred(chapter))) + } + + @Test + fun chapterFinishingFirstCancelsSlowOpeningGeneration() = runTest { + val chapterReady = CompletableDeferred() + var openingCancelled = false + val repository = repository { + try { + awaitCancellation() + } finally { + openingCancelled = true + } + } + val result = async { loadRsvpOpeningFrames(repository, book, config, 0, chapterReady) } + runCurrent() + chapterReady.complete(chapter) + + assertNull(result.await()) + assertTrue(openingCancelled) + } + + @Test + fun openingFailureLeavesChapterPreparationAvailable() = runTest { + val chapterReady = CompletableDeferred() + val repository = repository { error("Opening unavailable") } + + assertNull(loadRsvpOpeningFrames(repository, book, config, 0, chapterReady)) + assertFalse(chapterReady.isCancelled) + assertTrue(chapterReady.complete(chapter)) + } + + @Test + fun navigationActivatesReadyChapterAndRebasesScrubbingWithoutWaitingForTheOpening() = runTest { + val frames = List(100) { index -> + RsvpFrame(listOf(book.tokens[index]), 120L, originalTokenIndex = index, nextOriginalTokenIndex = index + 1) + } + val full = RsvpFrameSet(frames, 120L) + val preview = RsvpFrameSet(frames.subList(40, 80), 120L) + val runtime = RsvpRuntimeState().apply { + frameIndex = 5 + currentTokenIndex = 45 + isScrubbing = true + isPlaying = false + dragStartFrameIndex = 2 + } + var published: RsvpFrameSet? = null + val handoff = async { + awaitRsvpChapterHandoff(full, preview, runtime, book, config, repository { chapter }) { published = it } + } + runCurrent() + assertFalse(handoff.isCompleted) + + assertSame(full, requireNotNull(runtime.activatePreparedChapter).invoke()) + assertSame(full, published) + assertEquals(45, runtime.frameIndex) + assertEquals(42, runtime.dragStartFrameIndex) + assertTrue(runtime.isScrubbing) + runtime.frameIndex = 30 + Snapshot.sendApplyNotifications() + assertSame(full, handoff.await()) + assertEquals(30, runtime.frameIndex) + assertNull(runtime.activatePreparedChapter) + } + + @Test + fun cancelingHandoffDiscardsItsNavigationCallback() = runTest { + val frame = RsvpFrame(listOf(book.tokens.first()), 120L, originalTokenIndex = 0, nextOriginalTokenIndex = 1) + val preview = RsvpFrameSet(listOf(frame), 120L) + val runtime = RsvpRuntimeState() + val handoff = async { + awaitRsvpChapterHandoff(preview, preview, runtime, book, config, repository { chapter }) + } + runCurrent() + assertTrue(runtime.activatePreparedChapter != null) + handoff.cancel() + runCurrent() + assertNull(runtime.activatePreparedChapter) + } + + @Test + fun navigationWinsWhenPausedHandoffPreparationIsAlreadyInFlight() = runTest { + val frame = RsvpFrame(listOf(book.tokens.first()), 120L, originalTokenIndex = 0, nextOriginalTokenIndex = 1) + val full = RsvpFrameSet(listOf(frame), 120L) + val runtime = RsvpRuntimeState().apply { isPlaying = false } + val release = CompletableDeferred() + val delayed = object : RsvpFrameRepository by repository({ chapter }) { + override suspend fun getSeekableFrames( + bookId: BookId, + chapterIndex: Int, + config: RsvpConfig, + startIndex: Int, + options: RsvpGenerationOptions, + ): RsvpFrameSet { + release.await() + return full.copy() + } + } + val handoff = async { awaitRsvpChapterHandoff(full, full, runtime, book, config, delayed) } + runCurrent() + requireNotNull(runtime.activatePreparedChapter).invoke() + release.complete(Unit) + + assertSame("A stale paused handoff must not replace the chapter activated by navigation", full, handoff.await()) + } + + private fun repository(opening: suspend () -> RsvpFrameSet): RsvpFrameRepository = + object : RsvpFrameRepository { + override suspend fun getFrames(bookId: BookId, chapterIndex: Int, config: RsvpConfig, startIndex: Int): RsvpFrameSet = chapter + + override suspend fun getPreviewFrames( + tokens: List, + startIndex: Int, + config: RsvpConfig, + maxTokenCount: Int, + ): RsvpFrameSet = opening() + + override fun clearCache() = Unit + } +} diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpInlineContextTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpInlineContextTest.kt index 431f3d98..ca07e3ad 100644 --- a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpInlineContextTest.kt +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpInlineContextTest.kt @@ -8,6 +8,7 @@ import com.kairo.reader.core.model.RsvpContextAssistMode import com.kairo.reader.core.model.RsvpFrame import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import org.junit.Assert.assertEquals @@ -16,6 +17,17 @@ import org.junit.Assert.assertTrue import org.junit.Test class RsvpInlineContextTest { + @Test + fun clauseContextSharesTheContextualBoundaryDecision() { + val source = listOf(word("I"), word("want"), word("that").copy(isClauseBoundary = true), word("one")) + val frame = RsvpFrame(listOf(source[1]), 200L, originalTokenIndex = 1) + val context = requireNotNull( + buildRsvpReadingContext(source, frame, Color.Black, RsvpContextAssistMode.FULL_CLAUSE, RsvpLanguagePolicy.ENGLISH), + ) + assertEquals("I want that one", context.pausedPhrase.text) + assertEquals("that one", context.followingCues.first().text) + } + @Test fun cueContainsOnlyAlreadyReadWordsFromTheCurrentPhrase() { val tokens = "earlier sentence she had already left".split(" ").map(::word) diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpPlaybackStateTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpPlaybackStateTest.kt index 98dc7836..bd5a7860 100644 --- a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpPlaybackStateTest.kt +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpPlaybackStateTest.kt @@ -383,25 +383,20 @@ class RsvpPlaybackStateTest { } @Test - fun positionSyncGateSkipsFirstReadyFrameSetBeforeSyncingPosition() { - val gate = RsvpPositionSyncGate() + fun readyFrameSetsCanSaveTheirAlreadyAlignedPositionImmediately() { val initialFrames = frameListAt(12) val reloadedFrames = frameListAt(12) - assertFalse(gate.shouldSync(loadedFrameState(initialFrames))) - assertTrue(gate.shouldSync(loadedFrameState(initialFrames))) - assertFalse(gate.shouldSync(loadedFrameState(reloadedFrames))) - assertTrue(gate.shouldSync(loadedFrameState(reloadedFrames))) + assertTrue(shouldSyncPositionFromFrameState(loadedFrameState(initialFrames))) + assertTrue(shouldSyncPositionFromFrameState(loadedFrameState(reloadedFrames))) } @Test - fun positionSyncGateDoesNotArmFromLoadingPreviewFrames() { - val gate = RsvpPositionSyncGate() + fun positionSyncWaitsForLoadingPreviewToBeReplaced() { val frames = frameListAt(7) - assertFalse(gate.shouldSync(loadedFrameState(frames, isLoading = true))) - assertFalse(gate.shouldSync(loadedFrameState(frames))) - assertTrue(gate.shouldSync(loadedFrameState(frames))) + assertFalse(shouldSyncPositionFromFrameState(loadedFrameState(frames, isLoading = true))) + assertTrue(shouldSyncPositionFromFrameState(loadedFrameState(frames))) } @Test @@ -535,6 +530,15 @@ class RsvpPlaybackStateTest { assertFalse(savedCompleted) } + @Test + fun pausingClearsPreparationCreditBeforeAnExplicitResume() { + val runtime = RsvpRuntimeState().apply { startupPreparationMs = 500L } + + runtime.isPlaying = false + + assertEquals(0L, runtime.startupPreparationMs) + } + @Test fun persistedTempoDoesNotOverrideLocalTempoOverride() { val shouldApply = diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32926b42..2a939187 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "9.4.1" -kotlin = "2.4.20" +kotlin = "2.4.21" compileSdk = "37" targetSdk = "37" minSdk = "24"