diff --git a/README.md b/README.md index 4ca04d39..7d2a74f8 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ The result is an Android reader that sits somewhere between an ebook app, a spee - Clean full-screen RSVP playback with minimal interface clutter - ORP highlighting support -- Adaptive pacing based on word length, syllables, punctuation, clause boundaries, and difficulty +- Offline reading support with bounded word demand, real English word frequencies, and phrase breathing room - Optional peripheral context cues that stay inside the ORP focal band - Phrase replay by double tap or playback control, with temporary rereading-aware pace easing - Phrase chunking, blink modes, readability floors, and punctuation tuning diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt new file mode 100644 index 00000000..4d3dc35f --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt @@ -0,0 +1,228 @@ +package com.kairo.reader.ui.rsvp + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.ReaderTheme +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.ComprehensionRsvpEngine +import com.kairo.reader.core.rsvp.RsvpGenerationOptions +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import com.kairo.reader.ui.theme.KairoTheme +import com.kairo.reader.ui.theme.colorContrast +import com.kairo.reader.ui.theme.materialColorScheme +import com.kairo.reader.ui.theme.rsvpAccentColors +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RsvpDifficultyHighlightDeviceTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun darkThemesRenderAContrastingPivotLetterWithoutAHighlightBlock() { + var theme by mutableStateOf(ReaderTheme.DARK) + composeRule.setContent { + KairoTheme(readerTheme = theme) { + val scheme = MaterialTheme.colorScheme + val accents = scheme.rsvpAccentColors() + Box(Modifier.size(FOCUS_STAGE_WIDTH_DP.dp, FOCUS_STAGE_HEIGHT_DP.dp).background(scheme.background)) { + OrpAlignedText( + tokens = listOf(Token("reading", TokenType.WORD)), + typography = OrpTypography(FOCUS_FONT_SIZE_SP, FontFamily.Monospace, FontWeight.Normal), + colors = OrpColors(scheme.onBackground, accents.pivot, scheme.onBackground, accents.wordPart), + layout = OrpTextLayout( + horizontalBias = FOCUS_HORIZONTAL_BIAS, + lockPivot = true, + smoothTranslation = false, + preferWindowing = false, + simplifyPunctuation = false, + guideVisible = true, + pivotHighlightVisible = true, + guideThickness = FOCUS_GUIDE_THICKNESS, + ), + ) + } + } + } + + for (currentTheme in listOf(ReaderTheme.DARK, ReaderTheme.INK)) { + composeRule.runOnIdle { theme = currentTheme } + val scheme = currentTheme.materialColorScheme() + val accents = scheme.rsvpAccentColors() + val text = renderedLayout().layoutInput.text + val pivot = text.spanStyles.last() + assertEquals(PIVOT_CHARACTER_COUNT, pivot.end - pivot.start) + assertEquals(accents.pivot, pivot.item.color) + assertEquals(Color.Unspecified, pivot.item.background) + assertNull(pivot.item.fontWeight) + assertNull(pivot.item.textDecoration) + assertTrue(colorContrast(pivot.item.color, scheme.background) >= WCAG_AA_TEXT_CONTRAST) + } + } + + @Test + fun difficultWordsMoveLeftToRightWithoutMovingTheRenderedWord() { + val source = listOf("the", "neuroplasticity", "changed").map { Token(it, TokenType.WORD) } + val config = RsvpConfig( + maxChunkLength = 32, + enablePhraseChunking = false, + longWordMinMs = 400L, + startDelayMs = 0, + endDelayMs = 0, + rampUpFrames = 0, + rampDownFrames = 0, + ) + val engine = ComprehensionRsvpEngine() + val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + val parts = engine.generateFrames(source, 0, config, options).filter { it.originalTokenIndex == 1 } + assertTrue(parts.size > 1) + assertTrue(parts.all { it.durationMs >= config.longWordMinMs + it.protectedWordMs }) + var shown by mutableStateOf(parts.first().tokens) + composeRule.setContent { + KairoTheme { + Box(Modifier.size(360.dp, 180.dp)) { + OrpAlignedText( + tokens = shown, + typography = OrpTypography(28f, FontFamily.Monospace, FontWeight.Normal), + colors = OrpColors(Color.Black, Color.Red, Color.Gray, Color.Blue), + layout = OrpTextLayout( + horizontalBias = 0.5f, + lockPivot = true, + smoothTranslation = false, + preferWindowing = false, + simplifyPunctuation = false, + guideVisible = true, + pivotHighlightVisible = true, + guideThickness = 1f, + ), + ) + } + } + } + var initialPivot: Float? = null + for (part in parts) { + composeRule.runOnIdle { shown = part.tokens } + val token = part.tokens.single() + val layout = renderedLayout() + val text = layout.layoutInput.text + assertEquals("neuroplasticity", text.text) + assertEquals(token.text.substring(token.highlightStart!!, token.highlightEndExclusive!!), highlightedText(layout)) + val pivot = text.spanStyles.last { it.item.color == Color.Red }.start + val pivotX = layout.getBoundingBox(pivot).center.x + initialPivot?.let { assertEquals(it, pivotX, 0.001f) } + initialPivot = pivotX + } + } + + @Test + fun ordinaryInflectionsStayWholeWhileLongerTermsReceiveMovingParts() { + val words = listOf("Instructors", "representatives", "respresentatives", "responsibility") + val tokens = words.map { Token(it, TokenType.WORD) } + val config = RsvpProfile.BALANCED.defaultConfig().copy(enablePhraseChunking = false) + val frames = ComprehensionRsvpEngine().generateFrames(tokens, 0, config, RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)) + val ordinary = frames.filter { it.originalTokenIndex == 0 && !it.isWordSeparation } + assertEquals(1, ordinary.size) + assertEquals(null, ordinary.single().tokens.single().highlightStart) + for (index in 1..words.lastIndex) { + val parts = frames.filter { it.originalTokenIndex == index && !it.isWordSeparation }.flatMap { it.tokens } + assertTrue(words[index], parts.size > 1) + assertTrue(parts.all { it.isSubwordChunk && it.text == words[index] }) + assertEquals(0, parts.first().highlightStart) + assertEquals(words[index].length, parts.last().highlightEndExclusive) + } + } + + @Test + fun wordFamiliesKeepTheSamePresentationWithPackagedDataAndNormalPreset() { + val words = + listOf("instructor", "instructors", "university", "Universities", "representative", "representatives", "representative’s") + val config = RsvpProfile.BALANCED.defaultConfig().copy(enablePhraseChunking = false) + val frames = ComprehensionRsvpEngine().generateFrames( + words.map { Token(it, TokenType.WORD) }, + 0, + config, + RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH), + ) + fun parts(index: Int) = frames.filter { it.originalTokenIndex == index && !it.isWordSeparation }.flatMap { it.tokens } + for (index in 0..3) { + assertEquals(words[index], 1, parts(index).size) + assertEquals(null, parts(index).single().highlightStart) + } + val base = parts(4) + assertTrue(base.size > 1) + for (index in 5..6) { + val inflected = parts(index) + assertEquals(base.size, inflected.size) + assertEquals(base.dropLast(1).map { it.highlightEndExclusive }, inflected.dropLast(1).map { it.highlightEndExclusive }) + assertEquals(words[index].length, inflected.last().highlightEndExclusive) + } + } + + @Test + fun selectionDistinguishesFamiliarLongWordsFromUncommonShorterFamilies() { + val ordinary = listOf("comfortable", "everything", "conversation", "whispering", "explanation") + val supported = listOf("crucible", "Crucibles", "crystalline", "slateboard", "slateboards", "dormitory", "dormitories") + val frames = ComprehensionRsvpEngine().generateFrames( + (ordinary + supported).map { Token(it, TokenType.WORD) }, + 0, + RsvpProfile.BALANCED.defaultConfig(), + RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH), + ) + for (text in ordinary) { + val shown = frames.flatMap { it.tokens }.filter { it.text == text } + assertEquals(text, 1, shown.size) + assertTrue(shown.none { it.isSubwordChunk }) + } + for (text in supported) { + val shown = frames.flatMap { it.tokens }.filter { it.text == text } + assertTrue(text, shown.size > 1 && shown.all { it.isSubwordChunk }) + } + } + + private fun renderedLayout(): TextLayoutResult { + val layouts = mutableListOf() + composeRule.onNodeWithTag("rsvp-focus-word").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } + return layouts.single() + } + + private fun highlightedText(layout: TextLayoutResult): String { + val text = layout.layoutInput.text + return text.spanStyles.filter { it.item.color == Color.Blue }.joinToString("") { text.text.substring(it.start, it.end) } + } + + private companion object { + const val FOCUS_STAGE_WIDTH_DP = 360 + const val FOCUS_STAGE_HEIGHT_DP = 180 + const val FOCUS_FONT_SIZE_SP = 28f + const val FOCUS_HORIZONTAL_BIAS = 0.5f + const val FOCUS_GUIDE_THICKNESS = 1f + const val PIVOT_CHARACTER_COUNT = 1 + const val WCAG_AA_TEXT_CONTRAST = 4.5f + } +} diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt index c1b6b22d..0a61de0f 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt @@ -8,6 +8,8 @@ import com.kairo.reader.TestActivity 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.RsvpGenerationOptions +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy import com.kairo.reader.ui.theme.KairoTheme import org.junit.Assert.assertTrue import org.junit.Test @@ -54,5 +56,39 @@ class RsvpPunctuationTimingDeviceTest { } } + @Test + fun difficultWordAllowanceRemainsVisibleAfterEasyWords() { + val easy = Token("cat", TokenType.WORD, frequencyScore = 0.5, syllableCount = 1) + val difficult = easy.copy(text = "quizzacious") + val fixture = RsvpDeviceFixture( + listOf(easy, easy, easy, difficult, easy), + RsvpConfig( + tempoMsPerWord = 150L, smoothingAlpha = 0.01, maxSlowdownFactor = 1.01, + difficultWordSupport = 2.0, maxChunkLength = 0, + useAdaptiveTiming = false, useProsodyPacing = false, useFocalStress = false, + useClausePausing = false, useDialogueDetection = false, + startDelayMs = 0L, endDelayMs = 0L, rampUpFrames = 0, rampDownFrames = 0, + ), + ) + fixture.state = fixture.state.copy( + book = fixture.state.book.copy(generationOptions = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)), + ) + ActivityScenario.launch(TestActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.setContent { KairoTheme { RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) } } + } + val deadline = SystemClock.elapsedRealtime() + 15_000L + while (!fixture.finished && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20L) + assertTrue("Playback must finish", fixture.finished) + val index = fixture.consumed.indexOfFirst { it.originalTokenIndex == 3 } + assertTrue("Difficult word must be consumed after the warmup", index > 0) + val frame = fixture.consumed[index] + val displayedMs = fixture.consumedAtMs[index] - fixture.consumedAtMs[index - 1] + assertTrue("Reading allowance must survive smoothing", frame.protectedWordMs >= 160L) + assertTrue("Difficult word must retain its reading beat and allowance", frame.durationMs >= 290L) + assertTrue("Real playback must honour its duration: $displayedMs vs ${frame.durationMs}", displayedMs >= frame.durationMs) + } + } + private fun word(text: String) = Token(text, TokenType.WORD) } diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpReadingDemandDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpReadingDemandDeviceTest.kt new file mode 100644 index 00000000..781fce42 --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpReadingDemandDeviceTest.kt @@ -0,0 +1,47 @@ +package com.kairo.reader.ui.rsvp + +import android.os.SystemClock +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RsvpReadingDemandDeviceTest { + @Test + fun packagedLookupSupportsEnglishPlaybackAndRepeatedPreparationIsDeterministic() { + val words = listOf("the", "people", "considered", "quizzacious", "12345", "neuroplasticity", "again") + val tokens = List(5000) { Token(words[it % words.size], TokenType.WORD) } + val config = RsvpConfig( + enablePhraseChunking = false, + maxChunkLength = 32, + startDelayMs = 0L, + endDelayMs = 0L, + rampUpFrames = 0, + rampDownFrames = 0, + ) + val engine = ComprehensionRsvpEngine() + val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + val times = mutableListOf() + var expectedDurations: List? = null + repeat(3) { + val before = SystemClock.elapsedRealtimeNanos() + val frames = engine.generateFrames(tokens, 0, config, options) + times += (SystemClock.elapsedRealtimeNanos() - before) / 1_000_000L + assertEquals(tokens.indices.toList(), frames.map { it.originalTokenIndex }.distinct()) + assertTrue(frames[3].protectedWordMs > frames[0].protectedWordMs) + val durations = frames.map { frame -> frame.durationMs } + expectedDurations?.let { expected -> assertEquals(expected, durations) } + expectedDurations = durations + } + Log.i("RsvpDemandBenchmark", "5000 English words, full generation: first=${times.first()} ms, warm=${times.drop(1)} ms") + } +} diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpWordPartPlaybackDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpWordPartPlaybackDeviceTest.kt new file mode 100644 index 00000000..12a8a803 --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpWordPartPlaybackDeviceTest.kt @@ -0,0 +1,80 @@ +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.defaultConfig +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 + +/** Covers tokenization, repository, phrase generation and actual on-device playback together. */ +@RunWith(AndroidJUnit4::class) +class RsvpWordPartPlaybackDeviceTest { + @Test + fun normalProseReceivesAdvancingWordPartsDuringPlayback() { + val tokens = Tokenizer().tokenize( + Chapter( + index = 0, + title = "Word support fixture", + htmlContent = "", + plainText = "She saw the crystalline rock and made observations before the instructors came home.", + ), + ) + val config = RsvpProfile.BALANCED.defaultConfig().copy( + tempoMsPerWord = 131L, + maxChunkLength = 24, + difficultWordSupport = 2.0, + startDelayMs = 0L, + endDelayMs = 0L, + rampUpFrames = 0, + rampDownFrames = 0, + ) + val fixture = RsvpDeviceFixture(tokens, config) + fixture.state = fixture.state.copy( + book = fixture.state.book.copy(generationOptions = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)), + ) + ActivityScenario.launch(TestActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.setContent { KairoTheme { RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) } } + } + val deadline = SystemClock.elapsedRealtime() + 15_000L + while (!fixture.finished && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20L) + assertTrue("Playback must finish", fixture.finished) + for (text in listOf("crystalline", "observations")) { + assertPlayedParts(fixture, tokens.indexOfFirst { it.text == text }, text) + } + val ordinary = fixture.consumed.flatMap { it.tokens }.filter { it.text == "instructors" } + assertEquals(1, ordinary.size) + assertTrue(ordinary.none { it.isSubwordChunk }) + } + } + + private fun assertPlayedParts(fixture: RsvpDeviceFixture, sourceIndex: Int, text: String) { + val positions = fixture.consumed.indices.filter { index -> + fixture.consumed[index].tokens.any { it.text == text && it.isSubwordChunk } + } + assertTrue("$text needs moving highlights", positions.size > 1) + assertEquals("No gaps between parts", (positions.first()..positions.last()).toList(), positions) + val parts = positions.map { index -> + val frame = fixture.consumed[index] + assertEquals(sourceIndex, frame.originalTokenIndex) + assertTrue(!frame.isWordSeparation) + val displayedMs = fixture.consumedAtMs[index] - fixture.consumedAtMs[index - 1] + assertTrue("Part must remain visible for its reading time", displayedMs >= frame.durationMs) + frame.tokens.single { it.isSubwordChunk } + } + assertEquals(0, parts.first().highlightStart) + parts.zipWithNext().forEach { (left, right) -> assertEquals(left.highlightEndExclusive, right.highlightStart) } + assertEquals(text, parts.joinToString("") { it.text.substring(it.highlightStart!!, it.highlightEndExclusive!!) }) + } +} diff --git a/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt index a184f208..1b10da09 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt @@ -2,6 +2,9 @@ package com.kairo.reader.ui.settings import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.hasAnyAncestor @@ -9,11 +12,15 @@ import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction import androidx.test.ext.junit.runners.AndroidJUnit4 import com.kairo.reader.R import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.RsvpConfig import com.kairo.reader.core.model.UserPreferences import com.kairo.reader.ui.theme.KairoFocusedReadingTheme import com.kairo.reader.ui.theme.KairoTheme @@ -97,12 +104,50 @@ class RsvpSettingsParityTest { composeRule.onNode(isDialog()).assertDoesNotExist() } + @Test + fun readingSupportCommitsWithOnlyTwoEssentialSlidersAndFloorsRemainAdvanced() { + val prefs = mutableStateOf(UserPreferences()) + composeRule.setContent { + KairoTheme { + SettingsFixture( + prefs.value, + onConfigChange = { update -> prefs.value = prefs.value.copy(rsvpConfig = update(prefs.value.rsvpConfig)) }, + onBack = {}, + ) + } + } + val resources = composeRule.activity.resources + val sliders = SemanticsMatcher.keyIsDefined(SemanticsActions.SetProgress) + // The essential sliders are speed and reading support. + composeRule.onAllNodes(sliders).assertCountEquals(2) + composeRule.onNodeWithText(resources.getString(R.string.rsvp_difficult_word_support_title)).performScrollTo().assertIsDisplayed() + composeRule.onAllNodes(sliders)[1].performSemanticsAction(SemanticsActions.SetProgress) { it(150f) } + composeRule.runOnIdle { assertEquals(1.5, prefs.value.rsvpConfig.difficultWordSupport, 0.0) } + composeRule.onNodeWithText(resources.getString(R.string.rsvp_adaptive_pacing_title)).assertDoesNotExist() + val screenshot = composeRule.onRoot().captureToImage().asAndroidBitmap() + val directory = requireNotNull(composeRule.activity.getExternalFilesDir("ui-review")) + java.io.File(directory, "rsvp-comprehension-controls.png").outputStream().use { + screenshot.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) + } + composeRule.onNodeWithText(resources.getString(R.string.rsvp_difficult_word_support_title)).assertExists() + composeRule.onNodeWithText(resources.getString(R.string.settings_advanced_title)).performScrollTo().performClick() + composeRule.onNodeWithText(resources.getString(R.string.rsvp_readability_floors_title)).performScrollTo().performClick() + composeRule.onNodeWithText(resources.getString(R.string.rsvp_word_parts_title)).performScrollTo().assertIsDisplayed().performClick() + composeRule.runOnIdle { assertTrue(!prefs.value.rsvpConfig.showDifficultWordParts) } + composeRule.onNodeWithText(resources.getString(R.string.rsvp_min_word_time_title)).performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText(resources.getString(R.string.rsvp_long_word_min_title)).performScrollTo().assertIsDisplayed() + } + @androidx.compose.runtime.Composable - private fun SettingsFixture(prefs: UserPreferences, onBack: () -> Unit) { + private fun SettingsFixture( + prefs: UserPreferences, + onConfigChange: ((RsvpConfig) -> RsvpConfig) -> Unit = {}, + onBack: () -> Unit, + ) { RsvpSettingsScreen( preferences = prefs, onSelectRsvpProfile = {}, onSaveRsvpProfile = { _, _ -> }, onDeleteRsvpProfile = {}, - onRsvpTempoMsPerWordChange = {}, onRsvpConfigChange = {}, onUnlockExtremeSpeedChange = {}, + onRsvpTempoMsPerWordChange = {}, onRsvpConfigChange = onConfigChange, onUnlockExtremeSpeedChange = {}, onRsvpFontSizeChange = {}, onRsvpTextBrightnessChange = {}, onRsvpFontWeightChange = {}, onRsvpFontFamilyChange = {}, onRsvpVerticalBiasChange = {}, onRsvpHorizontalBiasChange = {}, onBack = onBack, diff --git a/app/src/main/assets/licenses/wordfreq-NOTICE.md b/app/src/main/assets/licenses/wordfreq-NOTICE.md new file mode 100644 index 00000000..7c5141c2 --- /dev/null +++ b/app/src/main/assets/licenses/wordfreq-NOTICE.md @@ -0,0 +1,76 @@ +Kairo English frequency lookup + +Derived from wordfreq 3.1.1 by Robyn Speer: https://github.com/rspeer/wordfreq +Kairo filters the first 30,000 entries to English alphabetic words and contractions, +exports integer hundredths of Zipf frequency, sorts and gzip-compresses the table. +This adapted data is licensed under CC BY-SA 4.0: +https://creativecommons.org/licenses/by-sa/4.0/ +Source and reproducible exporter: scripts/export-reading-frequency.py. +No wordfreq runtime code is bundled. SUBTLEX, by Marc Brysbaert et al., is freely available data. + +Upstream attribution follows: + +wordfreq +Copyright 2022 Robyn Speer + +# Attribution notes + +Robyn Speer must be credited as Robyn Speer, which is her maiden name, used on academic work. +Crediting her as Elia Robyn Lake (her married name) will make the credit less effective, as it will +not line up with other work. + +Crediting Robyn Speer by a different name than one of the above is a serious violation of the license, +in which case you do not have permission to use, copy, or redistribute wordfreq. + +If you use wordfreq in academic work, you must cite it. See "Citing wordfreq" in README.md. + +# Included licenses + +`wordfreq` is freely redistributable under the Apache license (see +`LICENSE.txt`), and it includes data files that may be +redistributed under a Creative Commons Attribution-ShareAlike 4.0 +license (). + +`wordfreq` contains data extracted from Google Books Ngrams +() and Google Books Syntactic Ngrams +(). +The terms of use of this data are: + + Ngram Viewer graphs and data may be freely used for any purpose, although + acknowledgement of Google Books Ngram Viewer as the source, and inclusion + of a link to http://books.google.com/ngrams, would be appreciated. + +`wordfreq` also contains data derived from the following Creative Commons-licensed +sources: + +- The Leeds Internet Corpus, from the University of Leeds Centre for Translation + Studies () + +- Wikipedia, the free encyclopedia () + +- ParaCrawl, a multilingual Web crawl () + +It contains data from OPUS OpenSubtitles 2018 +(), whose data originates from the +OpenSubtitles project () and may be used with +attribution to OpenSubtitles. + +It contains data from various SUBTLEX word lists: SUBTLEX-US, SUBTLEX-UK, +SUBTLEX-CH, SUBTLEX-DE, and SUBTLEX-NL, created by Marc Brysbaert et al. +(see citations below) and available at +. + +I (Robyn Speer) have obtained permission by e-mail from Marc Brysbaert to +distribute these wordlists in wordfreq, to be used for any purpose, not just +for academic use, under these conditions: + +- Wordfreq and code derived from it must credit the SUBTLEX authors. +- It must remain clear that SUBTLEX is freely available data. + +These terms are similar to the Creative Commons Attribution-ShareAlike license. + +Some additional data was collected by a custom application that watched the +streaming Twitter API, in accordance with Twitter's Developer Agreement & +Policy. This software gives statistics about words that were commonly used on +Twitter; it does not display or republish any Twitter content, and does not +contain any content from after Twitter's sale. 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 ef469717..bfc6073f 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 @@ -114,7 +114,7 @@ data class RsvpConfig( * Tempo in milliseconds for a baseline, easy word. * * This is the primary speed control for the engine. The actual time per unit is then shaped - * by readability floors, difficulty (length/syllables/rarity/complexity), punctuation, context, + * by readability floors, bounded reading demand, punctuation, context, * and rhythm smoothing. * * An estimated WPM can be derived from this, but WPM is not the direct control. @@ -128,7 +128,8 @@ data class RsvpConfig( val longWordMinMs: Long = 120L, val longWordChars: Int = 10, /** - * Difficulty model. + * Legacy difficulty fields retained only for lossless preference/profile round trips. + * Playback uses difficultWordSupport and the unified ReadingDemandAnalyzer instead. * - syllableExtraMs: additional time per syllable beyond the first. * - rarityExtraMaxMs: additional time for rare words (0..max) based on frequencyScore. * - complexityStrength: how strongly to apply Token.complexityMultiplier (0..1). @@ -137,7 +138,7 @@ data class RsvpConfig( val rarityExtraMaxMs: Long = 65L, val complexityStrength: Double = 0.65, /** - * Length curve. Adds time for longer words smoothly instead of abrupt thresholds. + * Legacy length curve, retained for profile compatibility; ignored by playback. * lengthStrength controls overall impact; lengthExponent controls how quickly it grows. */ val lengthStrength: Double = 0.9, @@ -206,7 +207,7 @@ data class RsvpConfig( /** * Rhythm shaping. * smoothingAlpha is EMA smoothing (0..1). Lower = steadier but less responsive. - * maxSpeedupFactor/maxSlowdownFactor clamp jitter between adjacent units. + * maxSpeedupFactor/maxSlowdownFactor clamp per-word beat changes, excluding protected difficulty time. */ val smoothingAlpha: Double = 0.35, val maxSpeedupFactor: Double = 1.25, @@ -234,12 +235,21 @@ data class RsvpConfig( /** Global punctuation breathing multiplier. 1.0 is neutral. */ val punctuationPauseFactor: Double = 1.08, val useAdaptiveTiming: Boolean = true, + /** Legacy adaptive difficulty cap; ignored by playback. */ val adaptiveDifficultyMaxHoldMs: Long = 70L, + /** Reading support (0..2) for both word recognition and phrase processing; retains its original storage key. */ + val difficultWordSupport: Double = 1.0, + /** Show selected long English words as moving word parts, independently of extra reading time. */ + val showDifficultWordParts: Boolean = true, + /** Legacy phrase allowance retained for profile compatibility; ignored by playback. */ + val phraseBreathingRoomMs: Long = adaptiveDifficultyMaxHoldMs, + /** Legacy complex-word hold; ignored by playback. */ val complexWordHoldMs: Long = 45L, val useClausePausing: Boolean = true, val useDialogueDetection: Boolean = true, val useProsodyPacing: Boolean = true, val prosodyStrength: Double = 1.0, + /** Legacy threshold; ignored by playback. */ val complexWordThreshold: Double = 1.3, val clausePauseFactor: Double = 1.25, val blinkMode: BlinkMode = BlinkMode.OFF, diff --git a/app/src/main/java/com/kairo/reader/core/model/RsvpConfigConstraints.kt b/app/src/main/java/com/kairo/reader/core/model/RsvpConfigConstraints.kt index 99f7b233..d0907285 100644 --- a/app/src/main/java/com/kairo/reader/core/model/RsvpConfigConstraints.kt +++ b/app/src/main/java/com/kairo/reader/core/model/RsvpConfigConstraints.kt @@ -2,6 +2,7 @@ package com.kairo.reader.core.model /** User-editable RSVP bounds shared by UI, persistence, profile codecs, and engine validation. */ object RsvpConfigConstraints { + const val MAX_DIFFICULT_WORD_SUPPORT = 2.0 const val PERCENT_SCALE = 100.0 const val MIN_WORD_MS = 20L diff --git a/app/src/main/java/com/kairo/reader/core/model/RsvpFrame.kt b/app/src/main/java/com/kairo/reader/core/model/RsvpFrame.kt index b8bd089d..7f4a13c5 100644 --- a/app/src/main/java/com/kairo/reader/core/model/RsvpFrame.kt +++ b/app/src/main/java/com/kairo/reader/core/model/RsvpFrame.kt @@ -30,4 +30,6 @@ data class RsvpFrame( // Live tempo changes scale the reading portion independently of this hold. // Null allows callers without generated timing to fall back to the punctuation policy. val punctuationHoldMs: Long? = null, + // Lexical and phrase processing time that must remain on the visible word. + val protectedWordMs: Long = 0L, ) diff --git a/app/src/main/java/com/kairo/reader/core/model/RsvpProfileDefaults.kt b/app/src/main/java/com/kairo/reader/core/model/RsvpProfileDefaults.kt index 1ef9451f..12d4f7ce 100644 --- a/app/src/main/java/com/kairo/reader/core/model/RsvpProfileDefaults.kt +++ b/app/src/main/java/com/kairo/reader/core/model/RsvpProfileDefaults.kt @@ -163,6 +163,8 @@ private fun skimReadingDefaults() = naturalReadingDefaults().copy( lengthStrength = 0.65, lengthExponent = 1.20, adaptiveDifficultyMaxHoldMs = 25L, + phraseBreathingRoomMs = 25L, + difficultWordSupport = 0.5, complexWordHoldMs = 20L, complexWordThreshold = 1.50, useProsodyPacing = false, @@ -204,6 +206,8 @@ private fun studyReadingDefaults() = naturalReadingDefaults().copy( lengthStrength = 1.05, lengthExponent = 1.35, adaptiveDifficultyMaxHoldMs = 100L, + phraseBreathingRoomMs = 100L, + difficultWordSupport = 1.5, complexWordHoldMs = 65L, complexWordThreshold = 1.20, prosodyStrength = 0.60, 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 f5874b83..0809d909 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 @@ -5,20 +5,15 @@ import com.kairo.reader.core.model.RsvpFrame 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.model.splitTokenForRsvp +import com.kairo.reader.core.rsvp.analysis.ReadingDemandAnalyzer import com.kairo.reader.core.rsvp.analysis.RsvpTokenAnalysis +import com.kairo.reader.core.rsvp.analysis.RsvpWordPartSupport import com.kairo.reader.core.rsvp.analysis.analyzeExpandedTokens import com.kairo.reader.core.rsvp.analysis.nextTokenAfter -import com.kairo.reader.core.rsvp.analysis.recordGivennessWord import com.kairo.reader.core.rsvp.analysis.shouldKeepFullFocalDuration import com.kairo.reader.core.rsvp.engine.BoundaryBefore import com.kairo.reader.core.rsvp.engine.ContextState import com.kairo.reader.core.rsvp.engine.ExpandedToken -import com.kairo.reader.core.rsvp.engine.FLOW_EMA_ALPHA -import com.kairo.reader.core.rsvp.engine.FLOW_MAX_BOOST -import com.kairo.reader.core.rsvp.engine.FLOW_MAX_SLOWDOWN -import com.kairo.reader.core.rsvp.engine.FLOW_STRENGTH -import com.kairo.reader.core.rsvp.engine.FlowState import com.kairo.reader.core.rsvp.engine.MAX_ANTICIPATORY_LANDING_BOOST import com.kairo.reader.core.rsvp.engine.MIN_FOCAL_SUPPORT_COMPRESSION import com.kairo.reader.core.rsvp.engine.MIN_FRAME_MS @@ -73,7 +68,7 @@ interface RsvpEngine { * * Core idea: * - Build *reading units* (1–2 word phrases + attached punctuation) that match language flow. - * - Compute unit durations via a difficulty model (length/syllables/rarity/complexity) + breath pauses. + * - Compute unit durations via one bounded reading-demand allowance + breath pauses. * - Apply context shaping (parentheticals/quotes) and rhythm shaping (EMA smoothing + jitter clamps). * * The result is a calm, legible cadence where long words and punctuation never "flash" away. @@ -115,7 +110,6 @@ private data class RsvpGenerationContext( val frames: MutableList, val state: ContextState, val rhythm: RhythmState, - val flow: FlowState, val prose: ProseState, ) @@ -128,7 +122,12 @@ private fun generateFramesWithNormalizedConfig( if (tokens.isEmpty()) return emptyList() val analysisStartIndex = resolveAnalysisStartIndex(tokens, startIndex) - val expanded = buildExpandedTokens(tokens, analysisStartIndex, config) + val expanded = ReadingDemandAnalyzer.attach( + buildExpandedTokens(tokens, analysisStartIndex, config, options.languagePolicy), + tokens, + analysisStartIndex, + options.languagePolicy, + ) val cursor = resolveFirstPlaybackCursor(expanded, startIndex) ?: return emptyList() val context = RsvpGenerationContext( @@ -146,8 +145,7 @@ private fun generateFramesWithNormalizedConfig( frames = mutableListOf(), state = createContextState(tokens, expanded[cursor].originalIndex), rhythm = createRhythmState(config), - flow = createFlowState(), - prose = createProseState(expanded, cursor, config), + prose = createProseState(expanded, cursor), ) var nextCursor = cursor @@ -163,16 +161,13 @@ private fun buildExpandedTokens( tokens: List, analysisStartIndex: Int, config: RsvpConfig, + languagePolicy: RsvpLanguagePolicy, ): List = tokens .subList(analysisStartIndex, tokens.size) .flatMapIndexed { index, token -> var sourceCursor = 0 - splitTokenForRsvp( - token = token, - maxChunkLength = config.maxChunkLength, - subwordChunkPauseMs = config.subwordChunkPauseMs, - ).map { splitToken -> + RsvpWordPartSupport.split(token, config, languagePolicy).map { splitToken -> val sourceStart = if (splitToken.text == token.text) { 0 @@ -261,18 +256,9 @@ private fun createRhythmState(config: RsvpConfig): RhythmState = maxSlowdownFactor = config.maxSlowdownFactor, ) -private fun createFlowState(): FlowState = - FlowState( - alpha = FLOW_EMA_ALPHA, - maxBoost = FLOW_MAX_BOOST, - maxSlowdown = FLOW_MAX_SLOWDOWN, - strength = FLOW_STRENGTH, - ) - private fun createProseState( expanded: List, playbackCursor: Int, - config: RsvpConfig, ): ProseState { val prose = ProseState() var previousWord: Token? = null @@ -283,9 +269,6 @@ private fun createProseState( when (token.type) { TokenType.WORD -> { prose.onWordShown() - if (config.useAdaptiveTiming) { - recordGivennessWord(token, prose) - } previousWord = token } TokenType.PUNCTUATION -> { @@ -340,7 +323,6 @@ private fun RsvpGenerationContext.appendBreakFrame(cursor: Int): Int? { displayOriginalEndCharacterOffset = expanded[cursor].sourceCharacterEndExclusive, ) rhythm.reset() - flow.reset() if (cursorToken.type == TokenType.PAGE_BREAK) { prose.onPageBreak() } else { @@ -412,7 +394,6 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { config = config, contextBefore = contextBefore, rhythm = rhythm, - flow = flow, prevToken = expanded.getOrNull(cursor - 1)?.token, prevWord = findPrevWord(expanded, beforeIndex = cursor), nextToken = expanded.getOrNull(nextCursor)?.token, @@ -431,6 +412,7 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { explicitSpeakerTag = selection.dialogueRole == RsvpDialogueRole.SPEAKER_TAG, thoughtCues = unitCues, + readingDemands = (wordCursor until nextCursor).mapNotNull { expanded[it].readingDemand }, ), ) @@ -439,6 +421,7 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { tokens = frameTokens, durationMs = timing.durationMs, punctuationHoldMs = timing.punctuationHoldMs, + protectedWordMs = timing.protectedWordMs, originalTokenIndex = frameOriginalIndex, resumeCursor = RsvpResumeCursor.fromCharacterOffset( diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpReadingDemand.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpReadingDemand.kt new file mode 100644 index 00000000..47c8eb93 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpReadingDemand.kt @@ -0,0 +1,169 @@ +@file:Suppress("MagicNumber") + +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 +import com.kairo.reader.core.rsvp.engine.ExpandedToken +import java.text.Normalizer +import java.util.Locale +import java.util.zip.GZIPInputStream +import kotlin.math.exp +import kotlin.math.ln + +/** One recognition estimate, with a separate small novelty allowance. Neither learns from the reader. */ +internal data class RsvpReadingDemand(val recognition: Double, val novelty: Double = 0.0, val share: Double = 1.0) { + val difficulty: Double get() = 1.0 - exp(-recognition) + + fun allowanceMs(tempoMs: Double): Double { + val cap = (tempoMs * 0.8).coerceIn(80.0, 180.0) + // Novelty consumes only the remaining headroom; it cannot inflate the recognition cap. + return share * cap * (1.0 - exp(-(recognition + novelty))) + } +} + +internal object ReadingDemandAnalyzer { + fun normalize(text: String): String = Normalizer.normalize(text, Normalizer.Form.NFC).lowercase(Locale.ROOT).replace('’', '\'') + .trim { !it.isLetterOrDigit() } + + fun analyze(token: Token, policy: RsvpLanguagePolicy, previousOccurrences: Int = 0): RsvpReadingDemand { + val word = normalize(token.text) + val length = word.codePoints().filter { Character.isLetterOrDigit(it) }.count().toInt() + val digits = word.count(Char::isDigit) + val lengthBase = if (policy == RsvpLanguagePolicy.CJK) 1 else 3 + val lengthScale = if (policy == RsvpLanguagePolicy.CJK) 2.0 else 4.0 + val visual = 0.55 * ln(1.0 + (length - lengthBase).coerceAtLeast(0) / lengthScale) + val english = policy == RsvpLanguagePolicy.ENGLISH && word.isNotEmpty() && word.all { it in 'a'..'z' || it == '\'' } + val rarity = when { + digits > 0 -> 0.35 * ln(1.0 + digits) + english -> EnglishReadingFrequency.recognitionZipf(word)?.let { ((5.5 - it) / 3.0).coerceIn(0.0, 1.0) } + ?: spellingFallback(word) + else -> 0.0 // No English frequency or pronunciation assumptions for other scripts/languages. + } + val novelty = if (english) { + 0.25 * rarity / (1.0 + previousOccurrences.coerceIn(0, 8)) + } else { + 0.0 + } + // Familiarity can help recognition, but cannot erase the work of decoding a long word. + // Use a floor, not another additive syllable allowance on top of length and rarity. + val decoding = if (english) decodingDemand(word, length) else 0.0 + return RsvpReadingDemand(maxOf(visual + 0.85 * rarity, decoding), novelty) + } + + /** Build from source positions before splitting. Seeking cannot turn a repeated term into a first occurrence. */ + fun plan(tokens: List, startIndex: Int, policy: RsvpLanguagePolicy): Map { + var paragraphStart = startIndex.coerceIn(0, tokens.size) + while (paragraphStart > 0 && + tokens[paragraphStart - 1].type != TokenType.PARAGRAPH_BREAK && + tokens[paragraphStart - 1].type != TokenType.PAGE_BREAK + ) { + paragraphStart-- + } + val seen = object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 2048 + } + val result = HashMap() + for (index in paragraphStart until tokens.size) { + val token = tokens[index] + when (token.type) { + TokenType.PARAGRAPH_BREAK, TokenType.PAGE_BREAK -> seen.clear() + TokenType.PUNCTUATION -> Unit + TokenType.WORD -> { + val key = normalize(token.text) + val count = seen[key] ?: 0 + if (index >= startIndex) result[index] = analyze(token, policy, count) + seen[key] = (count + 1).coerceAtMost(8) + } + } + } + return result + } + + fun attach( + expanded: List, + tokens: List, + startIndex: Int, + policy: RsvpLanguagePolicy, + ): List { + val demands = plan(tokens, startIndex, policy) + val sourceLengths = HashMap() + for (entry in expanded) { + if (entry.token.type == TokenType.WORD) { + sourceLengths.merge(entry.originalIndex, displayedLength(entry.token), Int::plus) + } + } + return expanded.map { entry -> + val demand = demands[entry.originalIndex] + entry.copy( + readingDemand = demand?.copy( + share = displayedLength(entry.token).toDouble() / sourceLengths.getValue(entry.originalIndex), + ) + ) + } + } + + fun displayedLength(token: Token): Int { + val start = token.highlightStart + val end = token.highlightEndExclusive + val validHighlight = start != null && end != null && start >= 0 && end > start && end <= token.text.length + val visible = if (token.isSubwordChunk && validHighlight) { + token.text.substring(requireNotNull(start), requireNotNull(end)) + } else { + token.text + } + return visible.codePoints().filter { Character.isLetterOrDigit(it) }.count().toInt().coerceAtLeast(1) + } + + private fun decodingDemand(word: String, length: Int): Double { + val vowelGroups = word.indices.count { index -> + word[index] in "aeiouy" && (index == 0 || word[index - 1] !in "aeiouy") + } + // Vowel groups are an orthographic cue, not a claim about exact spoken syllables. + val ending = word.removeSuffix("'s").removeSuffix("s") + val silentEnding = ending.endsWith("e") && !ending.endsWith("le") + val groups = (vowelGroups - if (silentEnding) 1 else 0).coerceAtLeast(1) + return 0.14 * (length - 7).coerceAtLeast(0) + 0.08 * (groups - 3).coerceAtLeast(0) + } + + private fun spellingFallback(word: String): Double { + var run = 0 + var longest = 0 + for (letter in word) { + run = if (letter in "aeiouy'") 0 else run + 1 + longest = maxOf(longest, run) + } + // Missing from a finite table is uncertainty, not proof that the word is maximally difficult. + return (0.65 + (longest - 2).coerceAtLeast(0) * 0.06).coerceAtMost(0.9) + } +} + +internal object EnglishReadingFrequency { + private val frequencies: Map by lazy { + val stream = checkNotNull(javaClass.getResourceAsStream("/reading/english-frequency.tsv.gz")) + GZIPInputStream(stream).bufferedReader(Charsets.UTF_8).useLines { lines -> + lines.associate { line -> + val separator = line.indexOf('\t') + line.substring(0, separator) to line.substring(separator + 1).toInt() + } + } + } + + fun zipf(word: String): Double? = frequencies[word]?.div(100.0) + + /** A familiar base form provides evidence for simple inflections, with a small recognition cost. */ + fun recognitionZipf(word: String): Double? { + val candidates = buildList { + zipf(word)?.let(::add) + val bases = buildList { + if (word.endsWith("'s")) add(word.dropLast(2)) + if (word.endsWith("s") && !word.endsWith("ss")) add(word.dropLast(1)) + if (word.endsWith("es")) add(word.dropLast(2)) + if (word.endsWith("ies")) add(word.dropLast(3) + "y") + } + bases.mapNotNull(::zipf).forEach { add(it - 0.15) } + } + return candidates.maxOrNull() + } +} 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 7319c8b6..91078853 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 @@ -15,6 +15,7 @@ internal data class RsvpThoughtCue( val isLastWord: Boolean, val protectedEmphasis: Boolean, val integrationHoldMs: Double, + val processingHoldMs: Double = 0.0, ) internal object RsvpThoughtPlan { @@ -31,7 +32,10 @@ internal object RsvpThoughtPlan { fun finish(endExclusive: Int) { if (words.isEmpty()) return val protected = protectedWords(words, english, previousModals) - val hold = integrationHold(words, config) + val hold = integrationHold(words, config, languagePolicy) + val sourceWords = words.distinctBy { it.originalIndex } + val processingShare = if (sourceWords.size > 1) PROCESSING_SHARE else 0.0 + val lastChunks = words.associateBy { it.originalIndex } val start = words.first().originalIndex words.forEachIndexed { index, word -> cues[word.expandedIndex] = RsvpThoughtCue( @@ -39,7 +43,12 @@ internal object RsvpThoughtPlan { endTokenIndexExclusive = endExclusive, isLastWord = index == words.lastIndex, protectedEmphasis = word.expandedIndex in protected, - integrationHoldMs = if (index == words.lastIndex) hold else 0.0, + integrationHoldMs = if (index == words.lastIndex) hold * (1.0 - processingShare) else 0.0, + processingHoldMs = if (lastChunks[word.originalIndex] == word) { + hold * processingShare / sourceWords.size + } else { + 0.0 + }, ) } previousModals = words.map { normalizeWord(it.token.text) }.filter { it in MODALS }.toSet() @@ -94,26 +103,36 @@ internal object RsvpThoughtPlan { } } - private fun integrationHold(words: List, config: RsvpConfig): Double { - if (!config.useAdaptiveTiming) return 0.0 - // Count source words once: spelling chunks must not manufacture cognitive load. - val sourceWords = words.distinctBy { it.originalIndex } - val informationWords = sourceWords.distinctBy { normalizeWord(it.token.text) } - val density = informationWords.sumOf { - (1.0 - it.token.frequencyScore).coerceIn(0.0, 1.0) + - (it.token.complexityMultiplier - 1.0).coerceIn(0.0, 1.0) + - if (it.token.text.any(Char::isDigit)) NUMBER_LOAD else 0.0 + private fun integrationHold( + words: List, + config: RsvpConfig, + languagePolicy: RsvpLanguagePolicy, + ): Double { + val informationWords = words.distinctBy { it.originalIndex } + .distinctBy { normalizeWord(it.token.text) } + // Without a language-specific familiarity model, word count is not evidence of density. + // Keep length and numbers as the shared fallback for non-English and unknown text. + val contentCount = if (languagePolicy == RsvpLanguagePolicy.ENGLISH) { + informationWords.count { + (EnglishReadingFrequency.zipf(ReadingDemandAnalyzer.normalize(it.token.text)) ?: 0.0) < COMMON_WORD_ZIPF + } + } else { + 0 } + val numbers = informationWords.count { it.token.text.any(Char::isDigit) } val lengthLoad = (informationWords.size - EASY_PHRASE_WORDS).coerceAtLeast(0).toDouble() - val load = (density - EASY_DENSITY).coerceAtLeast(0.0) + sqrt(lengthLoad) - return (load * HOLD_PER_LOAD_MS).coerceAtMost(config.adaptiveDifficultyMaxHoldMs.toDouble()) + val load = (contentCount - EASY_CONTENT_WORDS).coerceAtLeast(0) + numbers * NUMBER_LOAD + sqrt(lengthLoad) + return (load * HOLD_PER_LOAD_MS).coerceAtMost(PHRASE_ALLOWANCE_MS) * config.difficultWordSupport } private val NEGATIONS = setOf("not", "never", "neither", "nor", "no", "cannot", "can't", "won't", "isn't", "wasn't") private val CONTRAST_MARKERS = setOf("but", "instead", "rather", "however", "only", "except") private val MODALS = setOf("can", "could", "may", "might", "must", "shall", "should", "will", "would") + private const val COMMON_WORD_ZIPF = 5.0 + private const val EASY_CONTENT_WORDS = 3 + private const val PHRASE_ALLOWANCE_MS = 70.0 + private const val PROCESSING_SHARE = 0.4 private const val NUMBER_LOAD = 0.75 private const val EASY_PHRASE_WORDS = 7 - private const val EASY_DENSITY = 2.0 private const val HOLD_PER_LOAD_MS = 14.0 } 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 04e1ef24..ba330f35 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 @@ -5,11 +5,11 @@ import com.kairo.reader.core.linguistics.DialogueAnalyzer 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.RsvpLanguagePolicy import com.kairo.reader.core.rsvp.engine.ACRONYM_EMPHASIS_BOOST import com.kairo.reader.core.rsvp.engine.AUXILIARY_BRIDGE_MAX_CHARS import com.kairo.reader.core.rsvp.engine.AUXILIARY_BRIDGE_WORDS import com.kairo.reader.core.rsvp.engine.AUXILIARY_CONTENT_MAX_CHARS -import com.kairo.reader.core.rsvp.engine.AUXILIARY_CONTENT_MIN_FREQUENCY import com.kairo.reader.core.rsvp.engine.BoundaryBefore import com.kairo.reader.core.rsvp.engine.COHERENCE_HOLD_MS import com.kairo.reader.core.rsvp.engine.CONTENT_WORD_STRESS_BOOST @@ -20,8 +20,6 @@ import com.kairo.reader.core.rsvp.engine.FUNCTION_BRIDGE_WORDS import com.kairo.reader.core.rsvp.engine.FUNCTION_WORDS import com.kairo.reader.core.rsvp.engine.FUNCTION_WORD_GLIDE_LIGHT import com.kairo.reader.core.rsvp.engine.FUNCTION_WORD_GLIDE_STRONG -import com.kairo.reader.core.rsvp.engine.GIVENNESS_GLIDE -import com.kairo.reader.core.rsvp.engine.GIVENNESS_MIN_CHARS import com.kairo.reader.core.rsvp.engine.GLUE_WORDS import com.kairo.reader.core.rsvp.engine.MAX_BOUNDARY_TAIL_LIFT import com.kairo.reader.core.rsvp.engine.MAX_EMPHASIS_MULTIPLIER @@ -36,7 +34,6 @@ import com.kairo.reader.core.rsvp.engine.PHRASE_PRE_BOUNDARY_LIFT import com.kairo.reader.core.rsvp.engine.PRONOUN_BRIDGE_MAX_CHARS import com.kairo.reader.core.rsvp.engine.PRONOUN_BRIDGE_WORDS import com.kairo.reader.core.rsvp.engine.PROPER_NOUN_BOOST -import com.kairo.reader.core.rsvp.engine.ProseState import com.kairo.reader.core.rsvp.engine.SEMANTIC_ANCHOR_BOOST import com.kairo.reader.core.rsvp.engine.SEMANTIC_ANCHOR_WORDS import com.kairo.reader.core.rsvp.engine.TIGHT_PAIR_HINTS @@ -340,46 +337,6 @@ internal fun phraseBoundaryShapeMultiplier( return multiplier } -/** - * Given/new glide: words shown recently read lighter on re-mention. - * - * Only substantial non-function words are tracked, so the within-phrase evenness of glue words is - * untouched. Mutates [prose] (records this word as seen) — call exactly once per displayed word. - */ -internal fun givennessGlideMultiplier( - token: Token, - prose: ProseState, - speedStrength: Double, -): Double { - val key = givennessKey(token) ?: return 1.0 - - val given = prose.isGiven(key) - prose.record(key) - if (!given) return 1.0 - - return 1.0 - (GIVENNESS_GLIDE * speedStrength.coerceIn(0.0, 1.0)) -} - -internal fun recordGivennessWord( - token: Token, - prose: ProseState, -) { - val key = givennessKey(token) ?: return - prose.record(key) -} - -private fun givennessKey(token: Token): String? { - if (token.isSubwordChunk) return null - val key = givennessKey(token.text) - return key.takeIf { it.length >= GIVENNESS_MIN_CHARS && !isFunctionWord(it) } -} - -private fun givennessKey(text: String): String = - normalizeWord(text) - .removeSuffix("'s") - .removeSuffix("’s") - .filter { it.isLetter() } - internal fun isPhraseBreakBefore( currentLower: String, nextLower: String, @@ -417,12 +374,6 @@ internal fun isPhraseBreakBefore( return false } -internal fun frameDifficulty(words: List): Double { - if (words.isEmpty()) return 0.0 - val total = words.sumOf { (1.0 - wordEase(it)) } - return (total / words.size).coerceIn(0.0, 1.0) -} - internal fun shouldPreferHold( prev: Token, next: Token, @@ -437,7 +388,8 @@ internal fun shouldPreferHold( prev.text.length <= MAX_GLUE_PAIR_WORD_CHARS && next.text.length <= MAX_GLUE_PAIR_WORD_CHARS val easyPair = - wordEase(prev) >= EASY_PAIR_THRESHOLD && wordEase(next) >= EASY_PAIR_THRESHOLD + wordEase(prev, RsvpLanguagePolicy.ENGLISH) >= EASY_PAIR_THRESHOLD && + wordEase(next, RsvpLanguagePolicy.ENGLISH) >= EASY_PAIR_THRESHOLD // Check coherence score for high-coherence pairs val coherence = ClauseDetector.getCoherenceScore(prevLower, nextLower) @@ -474,6 +426,8 @@ internal fun analyzePhraseChunkPair( val prevLower = prev.text.lowercase() val nextLower = next.text.lowercase() val pairKey = "$prevLower $nextLower" + val previousFrequency = EnglishReadingFrequency.zipf(ReadingDemandAnalyzer.normalize(prev.text)) ?: 0.0 + val nextFrequency = EnglishReadingFrequency.zipf(ReadingDemandAnalyzer.normalize(next.text)) ?: 0.0 val pronounAuxiliaryBridge = prevLower in PRONOUN_BRIDGE_WORDS && @@ -484,7 +438,7 @@ internal fun analyzePhraseChunkPair( prevLower in AUXILIARY_BRIDGE_WORDS && nextLower !in SEMANTIC_ANCHOR_WORDS && next.text.length <= AUXILIARY_CONTENT_MAX_CHARS && - next.frequencyScore >= AUXILIARY_CONTENT_MIN_FREQUENCY + nextFrequency >= AUXILIARY_CONTENT_MIN_ZIPF val coherenceScore = ClauseDetector.getCoherenceScore(prevLower, nextLower) val coherentShortPair = coherenceScore >= PHRASE_CHUNK_COHERENCE_THRESHOLD && @@ -496,8 +450,8 @@ internal fun analyzePhraseChunkPair( prev.text.length <= MAX_GENERAL_CHUNK_PREV_CHARS && next.text.length <= MAX_GENERAL_CHUNK_NEXT_CHARS val bothCommon = - prev.frequencyScore >= COMMON_WORD_FREQUENCY_THRESHOLD && - next.frequencyScore >= COMMON_WORD_FREQUENCY_THRESHOLD + previousFrequency >= COMMON_WORD_ZIPF_THRESHOLD && + nextFrequency >= COMMON_WORD_ZIPF_THRESHOLD val generalShortPair = (glue && bothShort) || (bothShort && bothCommon) val tightPair = pairKey in TIGHT_PAIR_HINTS @@ -570,31 +524,8 @@ internal fun boundaryTailLiftWeight( ) } -internal fun wordEase(word: Token): Double { - val letters = word.text.count { it.isLetterOrDigit() }.coerceAtLeast(1) - val lengthScore = - ( - (letters - WORD_EASE_BASE_CHARS).coerceAtLeast(0) / - WORD_EASE_LENGTH_SCALE - ).coerceIn(0.0, 1.0) - val syllableScore = - ( - (word.syllableCount - 1).coerceAtLeast(0) / - WORD_EASE_SYLLABLE_SCALE - ).coerceIn(0.0, 1.0) - val rarityScore = (1.0 - word.frequencyScore).coerceIn(0.0, 1.0) - val complexityScore = (word.complexityMultiplier - 1.0).coerceAtLeast( - 0.0 - ).coerceIn(0.0, 1.0) - - val difficulty = - (lengthScore * WORD_EASE_LENGTH_WEIGHT) + - (syllableScore * WORD_EASE_SYLLABLE_WEIGHT) + - (rarityScore * WORD_EASE_RARITY_WEIGHT) + - (complexityScore * WORD_EASE_COMPLEXITY_WEIGHT) - - return (1.0 - difficulty).coerceIn(0.0, 1.0) -} +internal fun wordEase(word: Token, policy: RsvpLanguagePolicy = RsvpLanguagePolicy.UNKNOWN): Double = + 1.0 - ReadingDemandAnalyzer.analyze(word, policy).difficulty private const val MAX_SPEAKER_TAG_FRAME_WORDS = 3 private const val MIN_DIALOGUE_SPEED_STRENGTH = 0.15 @@ -610,12 +541,6 @@ private const val MAX_COHERENT_CHUNK_PREV_CHARS = 5 private const val MAX_COHERENT_CHUNK_NEXT_CHARS = 8 private const val MAX_GENERAL_CHUNK_PREV_CHARS = 4 private const val MAX_GENERAL_CHUNK_NEXT_CHARS = 7 -private const val COMMON_WORD_FREQUENCY_THRESHOLD = 0.7 +private const val COMMON_WORD_ZIPF_THRESHOLD = 4.5 +private const val AUXILIARY_CONTENT_MIN_ZIPF = 4.0 private const val FIXED_POINT_SCALE = 1000 -private const val WORD_EASE_BASE_CHARS = 4 -private const val WORD_EASE_LENGTH_SCALE = 8.0 -private const val WORD_EASE_SYLLABLE_SCALE = 4.0 -private const val WORD_EASE_LENGTH_WEIGHT = 0.35 -private const val WORD_EASE_SYLLABLE_WEIGHT = 0.25 -private const val WORD_EASE_RARITY_WEIGHT = 0.25 -private const val WORD_EASE_COMPLEXITY_WEIGHT = 0.15 diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPartSupport.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPartSupport.kt new file mode 100644 index 00000000..6048e95b --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPartSupport.kt @@ -0,0 +1,134 @@ +package com.kairo.reader.core.rsvp.analysis + +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.model.splitTokenForRsvp +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import java.util.Locale + +/** Presentation policy using family familiarity and spelling shape, independent of timing scores. */ +internal object RsvpWordPartSupport { + fun split(token: Token, config: RsvpConfig, policy: RsvpLanguagePolicy): List { + val base = automaticBase(token, config, policy) + val supported = base?.takeIf(::benefitsFromParts)?.let { + splitTokenForRsvp(token.copy(text = it), PART_LENGTH, config.subwordChunkPauseMs) + } + if (supported == null) return configuredSplit(token, config) + + // Keep the same boundaries across the family. A plural/possessive ending stays on the + // final part instead of moving every boundary or manufacturing an extra reading beat. + val parts = supported.mapIndexed { index, part -> + part.copy( + text = token.text, + highlightEndExclusive = if (index == supported.lastIndex) token.text.length else part.highlightEndExclusive, + ) + } + val exceedsExplicitLimit = parts.any { + requireNotNull(it.highlightEndExclusive) - requireNotNull(it.highlightStart) > config.maxChunkLength + } + return if (exceedsExplicitLimit) configuredSplit(token, config) else parts + } + + private fun automaticBase(token: Token, config: RsvpConfig, policy: RsvpLanguagePolicy): String? { + if (policy != RsvpLanguagePolicy.ENGLISH || + token.type != TokenType.WORD || + !config.showDifficultWordParts || + config.maxChunkLength <= PART_LENGTH || + token.isSubwordChunk + ) { + return null + } + val word = token.text.lowercase(Locale.ROOT).replace('’', '\'') + val stem = word.removeSuffix("'s").removeSuffix("'") + if (stem.isEmpty() || stem.any { it !in 'a'..'z' }) return null + return regularBase(stem) + } + + /** Regular inflections work without a dictionary; ambiguous -es/-is forms need an attested base. */ + private fun regularBase(word: String): String = when { + word.endsWith("es") && EnglishReadingFrequency.zipf(word.dropLast(ES_LENGTH) + "is") != null -> + word.dropLast(ES_LENGTH) + "is" + word.endsWith("ies") -> word.dropLast(IES_LENGTH) + "y" + SIBILANT_PLURALS.any(word::endsWith) -> word.dropLast(ES_LENGTH) + word.endsWith("s") && SINGULAR_S_ENDINGS.none(word::endsWith) -> word.dropLast(1) + else -> word + } + + private fun benefitsFromParts(base: String): Boolean { + val vowelGroups = base.indices.count { index -> + base[index] in VOWELS && (index == 0 || base[index - 1] !in VOWELS) + } + val silentEnding = base.endsWith("e") && !base.endsWith("le") + val groups = vowelGroups - if (silentEnding) 1 else 0 + if (base.length < UNCOMMON_BASE_LENGTH || groups < UNCOMMON_VOWEL_GROUPS) return false + // Very long spellings still benefit from parts even when the vocabulary is familiar. + if (base.length >= LONG_BASE_LENGTH && groups >= MIN_VOWEL_GROUPS) return true + val familiarity = familiarityZipf(base) + return when { + familiarity == null -> + // An absent entry is uncertain, so require stronger spelling evidence than + // for a known uncommon word. Short names and ordinary unknowns stay whole. + (base.length >= MIN_BASE_LENGTH && groups >= UNCOMMON_VOWEL_GROUPS) || + (base.length >= UNCOMMON_BASE_LENGTH && groups >= MIN_VOWEL_GROUPS) + familiarity <= UNCOMMON_ZIPF -> + base.length >= UNCOMMON_BASE_LENGTH && groups >= UNCOMMON_VOWEL_GROUPS + familiarity <= FAMILIAR_ZIPF -> + base.length >= MIN_BASE_LENGTH && groups >= MIN_VOWEL_GROUPS + else -> false + } + } + + private fun familiarityZipf(base: String): Double? { + var candidates = listOf(base) + var familiarity = EnglishReadingFrequency.zipf(base) + // Two bounded steps cover forms such as deepen-ing and cleanli-ness. + // Only an attested word supplies evidence; speculative stems never count as rare. + repeat(MAX_STEM_DEPTH) { depth -> + candidates = candidates.flatMap(::regularStems).distinct() + val attested = candidates.mapNotNull(EnglishReadingFrequency::zipf).maxOrNull() + ?.minus(STEM_FAMILIARITY_COST * (depth + 1)) + familiarity = listOfNotNull(familiarity, attested).maxOrNull() + } + return familiarity + } + + private fun regularStems(word: String): List { + val suffix = FAMILIAR_SUFFIXES.firstOrNull(word::endsWith) ?: return emptyList() + val stem = word.dropLast(suffix.length) + return buildList { + add(stem) + if (suffix in SILENT_E_SUFFIXES) add(stem + "e") + if (stem.endsWith("i")) add(stem.dropLast(1) + "y") + if (suffix in VERB_SUFFIXES && stem.length >= MIN_STEM_LENGTH && stem.last() == stem[stem.lastIndex - 1]) { + add(stem.dropLast(1)) + } + }.filter { it.length >= MIN_STEM_LENGTH } + } + + private fun configuredSplit(token: Token, config: RsvpConfig): List = + splitTokenForRsvp(token, config.maxChunkLength, config.subwordChunkPauseMs) + + private const val PART_LENGTH = 6 + + // Four vowel groups also occur in everyday prose at 10–13 letters. Requiring + // 14 made support effectively disappear outside unusually long terminology. + private const val MIN_BASE_LENGTH = 10 + private const val MIN_VOWEL_GROUPS = 4 + private const val LONG_BASE_LENGTH = 14 + private const val UNCOMMON_BASE_LENGTH = 8 + private const val UNCOMMON_VOWEL_GROUPS = 3 + private const val UNCOMMON_ZIPF = 3.5 + private const val FAMILIAR_ZIPF = 4.3 + private const val STEM_FAMILIARITY_COST = 0.15 + private const val MIN_STEM_LENGTH = 3 + private const val MAX_STEM_DEPTH = 2 + private const val IES_LENGTH = 3 + private const val ES_LENGTH = 2 + private const val VOWELS = "aeiouy" + private val SIBILANT_PLURALS = listOf("sses", "shes", "ches", "xes", "zzes") + private val SINGULAR_S_ENDINGS = listOf("ss", "us", "is", "ous") + private val FAMILIAR_SUFFIXES = listOf("ing", "ness", "ment", "less", "ed", "ly", "en") + private val SILENT_E_SUFFIXES = setOf("ing", "ed", "ment") + private val VERB_SUFFIXES = setOf("ing", "ed") +} diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpConfigNormalization.kt b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpConfigNormalization.kt index 762d1332..d652b494 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpConfigNormalization.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpConfigNormalization.kt @@ -8,6 +8,9 @@ import com.kairo.reader.core.model.effectiveBlinkMode internal fun RsvpConfig.normalizedForPlayback(): RsvpConfig { val safeMinWordMs = minWordMs.coerceAtLeast(1L) return copy( + difficultWordSupport = (difficultWordSupport.takeIf { it.isFinite() } ?: 1.0) + .coerceIn(0.0, Constraints.MAX_DIFFICULT_WORD_SUPPORT), + phraseBreathingRoomMs = phraseBreathingRoomMs.coerceIn(0L, Constraints.MAX_ADAPTIVE_HOLD_MS), tempoMsPerWord = tempoMsPerWord.coerceAtLeast(1L), minWordMs = safeMinWordMs, longWordMinMs = longWordMinMs.coerceAtLeast(safeMinWordMs), diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineConstants.kt b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineConstants.kt index 980b480e..8662039d 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineConstants.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineConstants.kt @@ -16,7 +16,6 @@ internal const val MAX_MIN_PAUSE_SCALE = 0.97 internal const val BASE_MS_PER_WORD_AT_300 = 200.0 internal const val DEFAULT_CLAUSE_PAUSE_FACTOR = 1.25 internal const val MIN_BLINK_MS = 16L -internal const val ADAPTIVE_DIFFICULTY_FLOOR = 0.35 internal const val CLAUSE_BOUNDARY_HOLD_MS = 55.0 internal const val ADAPTIVE_HOLD_MAX_MS = 160.0 internal const val EASY_PAIR_THRESHOLD = 0.72 @@ -46,10 +45,6 @@ internal const val PHRASE_ONSET_LIFT = 0.05 // time, later mentions read lighter. Tracked in a small LRU of recently shown content words, // cleared at page breaks (scene changes re-introduce). The glide also roughly cancels the // proper-noun emphasis boost on re-mentions. -internal const val GIVENNESS_GLIDE = 0.08 -internal const val GIVENNESS_MIN_CHARS = 5 -internal const val GIVENNESS_MAX_ENTRIES = 160 -internal const val GIVENNESS_INITIAL_CAPACITY = 64 // Em-dash role shaping. A dash is not one thing: paired dashes wrap an aside and should read as // a light dip in register (parenthesis-like), not two full clause stops in a row; a dash that is @@ -87,16 +82,11 @@ internal const val FUNCTION_BRIDGE_COHERENCE_THRESHOLD = 0.65 internal const val PRONOUN_BRIDGE_MAX_CHARS = 6 internal const val AUXILIARY_BRIDGE_MAX_CHARS = 6 internal const val AUXILIARY_CONTENT_MAX_CHARS = 8 -internal const val AUXILIARY_CONTENT_MIN_FREQUENCY = 0.45 internal const val MIN_PROSODY_MULTIPLIER = 0.88 internal const val MAX_PROSODY_MULTIPLIER = 1.18 internal const val CLAUSE_LEAD_BOOST_MS = 20.0 internal const val SENTENCE_END_BREAK_BOOST_MS = 40.0 internal const val EMBEDDED_QUOTE_FACTOR = 0.45 -internal const val FLOW_EMA_ALPHA = 0.25 -internal const val FLOW_MAX_BOOST = 0.04 -internal const val FLOW_MAX_SLOWDOWN = 0.05 -internal const val FLOW_STRENGTH = 0.12 internal const val MAX_BOUNDARY_TAIL_LIFT = 0.30 internal const val MIN_BOUNDARY_TAIL_LIFT_STRENGTH = 0.85 internal const val MIN_LANDING_HOLD_MS = 8.0 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 55a1e724..90be2dd4 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 @@ -2,6 +2,7 @@ package com.kairo.reader.core.rsvp.engine import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.analysis.RsvpReadingDemand import kotlin.math.max internal data class ExpandedToken( @@ -10,6 +11,7 @@ internal data class ExpandedToken( val expandedIndex: Int, val sourceCharacterStart: Int, val sourceCharacterEndExclusive: Int, + val readingDemand: RsvpReadingDemand? = null, ) internal data class PhraseContour(val preBoundaryWeight: Double, val restartWeight: Double,) { @@ -71,24 +73,13 @@ internal data class ContextSnapshot(val parentheticalDepth: Int, val inDialogue: * Sequential prose memory carried across frames during generation. * * Tracks how deep into the current sentence the reader is (for sentence wrap-up pauses) and - * which content words have been shown recently (for given/new pacing). Frames are generated in + * phrase timing. Frames are generated in * reading order, so this state is deterministic for a given token stream. */ internal class ProseState { var wordsInSentence: Int = 0 private set - private val seenWords = - object : LinkedHashMap( - GIVENNESS_INITIAL_CAPACITY, - GIVENNESS_CACHE_LOAD_FACTOR, - true, - ) { - override fun removeEldestEntry( - eldest: MutableMap.MutableEntry? - ): Boolean = size > GIVENNESS_MAX_ENTRIES - } - fun onWordShown() { wordsInSentence++ } @@ -103,19 +94,9 @@ internal class ProseState { fun onPageBreak() { wordsInSentence = 0 - seenWords.clear() - } - - /** Whether [key] was shown recently; refreshes its recency when found. */ - fun isGiven(key: String): Boolean = seenWords[key] != null - - fun record(key: String) { - seenWords[key] = true } } -private const val GIVENNESS_CACHE_LOAD_FACTOR = 0.75f - internal class RhythmState { private var ema: Double? = null private val smoothingAlpha: Double @@ -176,38 +157,3 @@ internal class RhythmState { private const val RHYTHM_BOUNDARY_SCALE = 1000 private const val PARTIAL_BOUNDARY_RESEED_MAX = 0.25 - -internal class FlowState( - private val alpha: Double, - private val maxBoost: Double, - private val maxSlowdown: Double, - private val strength: Double, -) { - private var ema: Double? = null - - fun apply( - difficulty: Double, - speedStrength: Double, - isBoundary: Boolean, - ): Double { - if (isBoundary) { - ema = difficulty - return 1.0 - } - - val prev = ema ?: difficulty - val delta = difficulty - prev - - // Gentle flow adjustment - reduce variation for smoother cadence - val multiplier = - (1.0 + (delta * strength * speedStrength)) - .coerceIn(1.0 - maxSlowdown, 1.0 + maxBoost) - - ema = prev + (alpha * (difficulty - prev)) - return multiplier - } - - fun reset() { - ema = null - } -} diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt index 072d0c48..2bc2702e 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt @@ -56,13 +56,20 @@ private fun splitFrameForBlink( val firstWord = frame.tokens.singleWordOrNull() val nextWord = nextTokens.singleWordOrNull() if (firstWord == null || nextWord == null) return listOf(frame) + // A moving highlight is one continuous word, not a repeated-word flash. + if (firstWord.isSubwordChunk && + nextWord.isSubwordChunk && + frame.originalTokenIndex == next?.originalTokenIndex + ) { + return listOf(frame) + } val shouldHold = frame.tokens.none { it.type == TokenType.PUNCTUATION } && shouldPreferHold(firstWord, nextWord) val repeatedWord = firstWord.text.equals(nextWord.text, ignoreCase = true) if ((!repeatedWord && shouldHold) || isHardBoundary(frame.tokens, nextWord)) return listOf(frame) - val floorMs = max(wordFloorMs(firstWord, config), MIN_FRAME_MS) + val floorMs = max(wordFloorMs(firstWord, config), MIN_FRAME_MS) + frame.protectedWordMs val maxBlink = (frame.durationMs - floorMs - (frame.punctuationHoldMs ?: 0L)).coerceAtLeast(0L) val punctuationFactor = blinkPunctuationFactor(frame.tokens) val blinkMs = min((WORD_SEPARATION_MS * punctuationFactor).roundToLong(), maxBlink) diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpDpSegmenter.kt b/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpDpSegmenter.kt index d9d78e16..1bf46f17 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpDpSegmenter.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpDpSegmenter.kt @@ -139,7 +139,7 @@ internal object RsvpDpSegmenter { val visibleCharacters = visibleCodePointCount(token.text) val difficulty = if (languagePolicy == RsvpLanguagePolicy.ENGLISH) { - ((1.0 - wordEase(token)) * RsvpSegmentationWeightsV2.FIXED_POINT_SCALE) + ((1.0 - wordEase(token, languagePolicy)) * RsvpSegmentationWeightsV2.FIXED_POINT_SCALE) .roundToInt() .coerceIn(0, RsvpSegmentationWeightsV2.FIXED_POINT_SCALE) } else { diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt index 51f69d17..7226bc53 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt @@ -7,7 +7,9 @@ import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType import com.kairo.reader.core.model.isSentenceEndingPunctuation import com.kairo.reader.core.model.wordFloorMsForReadability -import com.kairo.reader.core.rsvp.engine.ADAPTIVE_DIFFICULTY_FLOOR +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import com.kairo.reader.core.rsvp.analysis.ReadingDemandAnalyzer +import com.kairo.reader.core.rsvp.analysis.RsvpReadingDemand import com.kairo.reader.core.rsvp.engine.ADAPTIVE_HOLD_MAX_MS import com.kairo.reader.core.rsvp.engine.BASE_MS_PER_WORD_AT_300 import com.kairo.reader.core.rsvp.engine.BoundaryBefore @@ -38,30 +40,19 @@ import com.kairo.reader.core.rsvp.text.isEmbeddedQuote import com.kairo.reader.core.rsvp.text.isQuoteChar import kotlin.math.max import kotlin.math.pow -import kotlin.math.roundToLong internal fun adaptiveHoldMs( words: List, - difficulty: Double, config: RsvpConfig, speedStrength: Double, hardBoundary: Boolean, nextWord: Token?, clauseConfigStrength: Double, ): Double { - if (!config.useAdaptiveTiming || words.isEmpty() || nextWord == null) return 0.0 + if (!config.useAdaptiveTiming || words.isEmpty()) return 0.0 - val difficultyScale = - ( - (difficulty - ADAPTIVE_DIFFICULTY_FLOOR).coerceAtLeast(0.0) / - (1.0 - ADAPTIVE_DIFFICULTY_FLOOR) - ) - .coerceIn(0.0, 1.0) - var hold = difficultyScale * config.adaptiveDifficultyMaxHoldMs * speedStrength - - if (words.any { it.complexityMultiplier >= config.complexWordThreshold }) { - hold += config.complexWordHoldMs * speedStrength - } + if (nextWord == null) return 0.0 + var hold = 0.0 val lastWord = words.lastOrNull() if (!hardBoundary && config.useClausePausing && lastWord?.isClauseBoundary == true) { @@ -86,56 +77,28 @@ internal fun adaptiveHoldMs( return hold.coerceAtMost(ADAPTIVE_HOLD_MAX_MS * speedStrength) } -internal fun wordDurationMs( +internal data class RsvpWordTimingBudget(val beatMs: Double, val protectedMs: Double) + +internal fun wordDurationMs(word: Token, msPerWord: Double, config: RsvpConfig): Double = + wordTimingBudget(word, msPerWord, config).let { it.beatMs + it.protectedMs } + +internal fun wordTimingBudget( word: Token, msPerWord: Double, config: RsvpConfig, -): Double { + readingDemand: RsvpReadingDemand? = null, +): RsvpWordTimingBudget { val text = word.text - val fullLetters = text.count { it.isLetterOrDigit() }.coerceAtLeast(1) - val (letters, syllables) = - if (word.isSubwordChunk && - word.highlightStart != null && - word.highlightEndExclusive != null && - word.highlightEndExclusive > word.highlightStart && - word.highlightEndExclusive <= text.length - ) { - val chunkText = text.substring(word.highlightStart, word.highlightEndExclusive) - val chunkLetters = chunkText.count { it.isLetterOrDigit() }.coerceAtLeast(1) - val ratio = - (chunkLetters.toDouble() / fullLetters.toDouble()) - .coerceIn(MIN_SUBWORD_LENGTH_RATIO, 1.0) - val scaledSyllables = - max(1.0, word.syllableCount.toDouble() * ratio).roundToLong().toInt() - chunkLetters to scaledSyllables - } else { - fullLetters to word.syllableCount - } - - val lengthCurve = - run { - val x = - ( - (letters - LENGTH_CURVE_BASE_CHARS).coerceAtLeast(0) / - LENGTH_CURVE_CHAR_SCALE - ) - 1.0 + config.lengthStrength * (x.pow(config.lengthExponent)) - } - - val complexityComponent = - 1.0 + (max(0.0, word.complexityMultiplier - 1.0) * config.complexityStrength) - - val rarityExtra = (1.0 - word.frequencyScore).coerceIn(0.0, 1.0) * config.rarityExtraMaxMs - val syllableExtra = max(0, syllables - 1) * config.syllableExtraMs - - var duration = (msPerWord * lengthCurve * complexityComponent) + rarityExtra + syllableExtra + val demand = readingDemand ?: ReadingDemandAnalyzer.analyze(word, RsvpLanguagePolicy.UNKNOWN) + val difficultyExtra = demand.allowanceMs(msPerWord) * config.difficultWordSupport + var duration = msPerWord // Dynamism: let easy, predictable words glide below the baseline tempo so the cadence rises // and falls with difficulty rather than only ever adding time. Hard words are untouched (they // already earned length/rarity time). Mid-word continuations (hyphen/subword) are left alone, // and the unit-level word floor downstream keeps every frame readable. if (!word.isSubwordChunk && !text.endsWith("-")) { - val ease = word.frequencyScore.coerceIn(0.0, 1.0) + val ease = 1.0 - demand.difficulty if (ease > DYNAMISM_EASE_PIVOT) { val t = (ease - DYNAMISM_EASE_PIVOT) / (1.0 - DYNAMISM_EASE_PIVOT) val compressionStrength = speedStrength(msPerWord) @@ -143,15 +106,11 @@ internal fun wordDurationMs( } } - if (letters >= config.longWordChars) { - duration = max(duration, config.longWordMinMs.toDouble()) - } - + var protectedMs = difficultyExtra if (text.endsWith("-")) { - duration += msPerWord * HYPHEN_CONTINUATION_HOLD_FACTOR + protectedMs += msPerWord * HYPHEN_CONTINUATION_HOLD_FACTOR } - - return duration + return RsvpWordTimingBudget(beatMs = duration, protectedMs = protectedMs) } internal fun punctuationPauseMs( @@ -389,9 +348,6 @@ private const val BASE_EXPRESSION_STRENGTH = 0.30 private const val PHRASE_END_HOLD_FACTOR = 0.6 private const val COHERENCE_GROUP_THRESHOLD = 0.5 private const val COHERENCE_HOLD_REDUCTION = 0.4 -private const val MIN_SUBWORD_LENGTH_RATIO = 0.2 -private const val LENGTH_CURVE_BASE_CHARS = 4 -private const val LENGTH_CURVE_CHAR_SCALE = 10.0 private const val HYPHEN_CONTINUATION_HOLD_FACTOR = 0.25 private const val MIN_PAUSE_TEMPO_RATIO = 0.12 private const val MAX_PAUSE_TEMPO_RATIO = 2.5 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 3cf64d5c..4f822c1d 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,11 +7,10 @@ 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.analysis.RsvpReadingDemand import com.kairo.reader.core.rsvp.analysis.RsvpThoughtCue import com.kairo.reader.core.rsvp.analysis.contextShapingMultiplier import com.kairo.reader.core.rsvp.analysis.emphasisMultiplier -import com.kairo.reader.core.rsvp.analysis.frameDifficulty -import com.kairo.reader.core.rsvp.analysis.givennessGlideMultiplier import com.kairo.reader.core.rsvp.analysis.isEmDashChar import com.kairo.reader.core.rsvp.analysis.multiWordPenalty import com.kairo.reader.core.rsvp.analysis.phraseBoundaryShapeMultiplier @@ -28,7 +27,6 @@ import com.kairo.reader.core.rsvp.engine.DIALOGUE_ENTRY_BOOST import com.kairo.reader.core.rsvp.engine.EM_DASH_ASIDE_CONTOUR_FACTOR import com.kairo.reader.core.rsvp.engine.EM_DASH_ASIDE_PAUSE_FACTOR import com.kairo.reader.core.rsvp.engine.EM_DASH_INTERRUPTION_PAUSE_FACTOR -import com.kairo.reader.core.rsvp.engine.FlowState 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 @@ -49,7 +47,6 @@ internal data class RsvpUnitTimingInput( val config: RsvpConfig, val contextBefore: ContextSnapshot, val rhythm: RhythmState, - val flow: FlowState, val prevToken: Token?, val prevWord: Token?, val nextToken: Token?, @@ -65,6 +62,7 @@ internal data class RsvpUnitTimingInput( val rhythmBoundaryStrengthMilli: Int = 0, val explicitSpeakerTag: Boolean = false, val thoughtCues: List = emptyList(), + val readingDemands: List = emptyList(), ) private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { @@ -72,7 +70,6 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { val config = input.config val contextBefore = input.contextBefore val rhythm = input.rhythm - val flow = input.flow val prevToken = input.prevToken val prevWord = input.prevWord val nextToken = input.nextToken @@ -88,6 +85,7 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { val rhythmBoundaryStrengthMilli = input.rhythmBoundaryStrengthMilli val explicitSpeakerTag = input.explicitSpeakerTag val thoughtCues = input.thoughtCues + val readingDemands = input.readingDemands val msPerWord = config.tempoMsPerWord.toDouble() val pauseScale = pauseScale(msPerWord, config) val clausePauseScale = @@ -190,6 +188,8 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { private data class FrameWordTiming( val duration: Double, val expressionMs: Double, + val protectedMs: Double, + val pauseMs: Double, val enteredDialogue: Boolean, val exitedDialogue: Boolean, val sawParentheticalWord: Boolean, @@ -203,6 +203,8 @@ private class FrameWordContext(contextBefore: ContextSnapshot) { var sawParentheticalWord = false var wordOrdinal = 0 var expressionMs = 0.0 + var protectedMs = 0.0 + var pauseMs = 0.0 } private fun computeFrameWordTiming(context: RsvpUnitTimingContext): FrameWordTiming { @@ -214,7 +216,7 @@ private fun computeFrameWordTiming(context: RsvpUnitTimingContext): FrameWordTim TokenType.WORD -> { duration += wordDurationContribution(context, state, index, token) context.prose?.onWordShown() - if (token.pauseAfterMs > 0L) duration += token.pauseAfterMs * context.pauseScale + if (token.pauseAfterMs > 0L) state.pauseMs += token.pauseAfterMs * context.pauseScale } else -> Unit } @@ -222,6 +224,8 @@ private fun computeFrameWordTiming(context: RsvpUnitTimingContext): FrameWordTim return FrameWordTiming( duration = duration, expressionMs = state.expressionMs, + protectedMs = state.protectedMs, + pauseMs = state.pauseMs, enteredDialogue = state.enteredDialogue, exitedDialogue = state.exitedDialogue, sawParentheticalWord = state.sawParentheticalWord, @@ -254,6 +258,7 @@ private fun wordDurationContribution( token: Token, ): Double { val config = context.config + val demand = context.readingDemands.getOrNull(state.wordOrdinal) val thoughtCue = context.thoughtCues.getOrNull(state.wordOrdinal++) val protectedEmphasis = config.useProsodyPacing && thoughtCue?.protectedEmphasis == true val inAside = config.useParentheticalAside && (state.parentheticalDepth > 0 || context.emDashAside) @@ -324,14 +329,10 @@ private fun wordDurationContribution( } else { 1.0 } - val givenness = - if (config.useAdaptiveTiming && context.prose != null) { - givennessGlideMultiplier(token, context.prose, context.speedStrength) - } else { - 1.0 - } + val wordBudget = wordTimingBudget(token, context.msPerWord, config, demand) + state.protectedMs += wordBudget.protectedMs val duration = - wordDurationMs(token, context.msPerWord, config) * + wordBudget.beatMs * parentheticalMultiplier * dialogueMultiplier * context.speakerTagMultiplier @@ -346,10 +347,9 @@ private fun wordDurationContribution( context.anticipatoryLanding, if (config.useProsodyPacing) phraseContourMultiplier(context.phraseContour, context.expressionStrength) else 1.0, context.phraseShapeMultiplier, - if (protectedEmphasis) 1.0 else givenness, if (protectedEmphasis) 1.0 + (THOUGHT_EMPHASIS * context.prosodyStrength) else 1.0, ) - val baseline = max(duration, wordFloorMs(token, config).toDouble()) + val baseline = duration state.expressionMs += baseline * (expression - 1.0) return baseline } @@ -459,14 +459,14 @@ private fun punctuationPauseForToken( private val CLOSING_ASIDE_PUNCTUATION = setOf(')', ']', '}') -internal data class RsvpUnitTiming(val durationMs: Long, val punctuationHoldMs: Long) +internal data class RsvpUnitTiming(val durationMs: Long, val punctuationHoldMs: Long, val protectedWordMs: Long) internal fun computeUnitDurationMs(input: RsvpUnitTimingInput): Long = computeUnitTiming(input).durationMs internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = with(RsvpUnitTimingContext(input)) { val wordTiming = computeFrameWordTiming(this) - var duration = wordTiming.duration + val duration = wordTiming.duration val transitionHold = transitionHoldMs( @@ -476,29 +476,16 @@ internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = speedStrength = speedStrength, prosodyStrength = prosodyStrength, ) - if (transitionHold > 0.0) { - duration += transitionHold - } - duration *= multiWordPenalty(words.size) - - // Apply flow and rhythm smoothing to word duration BEFORE adding punctuation pauses. - // This ensures punctuation pauses are not reduced by the smoothing algorithms. + // Smooth the per-word beat, so changing frame width cannot consume reading time. + // Difficulty, split-word holds and phrase processing bypass this smoother. val hardBoundary = isHardBoundary(frameTokens, nextToken) - val difficulty = frameDifficulty(words) - duration *= - flow.apply( - difficulty = difficulty, - speedStrength = speedStrength, - isBoundary = hardBoundary, - ) - val smoothedWordDuration = rhythm.apply( - rawMs = duration, + rawMs = duration / words.size.coerceAtLeast(1), isBoundary = hardBoundary, boundaryStrengthMilli = rhythmBoundaryStrengthMilli, - ) + ) * words.size.coerceAtLeast(1) * multiWordPenalty(words.size) // Now add punctuation pauses on top of the smoothed word duration. // These pauses are intentionally NOT smoothed so they remain prominent. @@ -510,6 +497,9 @@ internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = smoothedWordDuration * expression, words.sumOf { wordFloorMs(it, config).toDouble() }, ) + // Reading support is extra exposure, even when the readability floor exceeds the beat. + totalDuration += wordTiming.protectedMs + totalDuration += wordTiming.pauseMs + transitionHold + thoughtCues.sumOf { it.processingHoldMs } var integrationHold = thoughtCues.sumOf { it.integrationHoldMs } if (words.isNotEmpty()) { when (boundaryForBoost) { @@ -556,17 +546,16 @@ 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, + ) + totalDuration += adaptiveHold if (paragraphBreaks == 0 && pageBreaks == 0) { - totalDuration += - adaptiveHoldMs( - words = words, - difficulty = difficulty, - config = config, - speedStrength = speedStrength, - hardBoundary = hardBoundary, - nextWord = nextWord, - clauseConfigStrength = clauseConfigStrength, - ) if (!hardBoundary && config.useClausePausing && nextWord?.isClauseBoundary == true @@ -596,6 +585,10 @@ internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = return RsvpUnitTiming( durationMs = totalDuration.toLong().coerceAtLeast(MIN_FRAME_MS), + protectedWordMs = ( + wordTiming.protectedMs + wordTiming.pauseMs + adaptiveHold + transitionHold + + thoughtCues.sumOf { it.processingHoldMs } + if (punctuationHold > 0.0) 0.0 else integrationHold + ).toLong(), punctuationHoldMs = (punctuationHold + if (punctuationHold > 0.0) integrationHold else 0.0).toLong(), ) } diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt index 57906635..5ea7e814 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt @@ -61,6 +61,9 @@ internal object PrefKeys { val rampUpFrames = intPreferencesKey("ramp_up_frames") val rampDownFrames = intPreferencesKey("ramp_down_frames") val useAdaptiveTiming = booleanPreferencesKey("use_adaptive_timing") + val difficultWordSupport = doublePreferencesKey("difficult_word_support") + val showDifficultWordParts = booleanPreferencesKey("show_difficult_word_parts") + val phraseBreathingRoomMs = longPreferencesKey("phrase_breathing_room_ms") val adaptiveDifficultyMaxHoldMs = longPreferencesKey("adaptive_difficulty_max_hold_ms") val complexWordHoldMs = longPreferencesKey("complex_word_hold_ms") val complexWordThreshold = doublePreferencesKey("complex_word_threshold") diff --git a/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt b/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt index c6dc765c..95cc8121 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt @@ -132,6 +132,9 @@ internal class RsvpConfigPreferenceCodec(private val keys: PrefKeys, private val private fun writeAdaptiveTiming(prefs: MutablePreferences, config: RsvpConfig) { prefs[keys.useAdaptiveTiming] = config.useAdaptiveTiming prefs[keys.adaptiveDifficultyMaxHoldMs] = config.adaptiveDifficultyMaxHoldMs + prefs[keys.difficultWordSupport] = config.difficultWordSupport + prefs[keys.showDifficultWordParts] = config.showDifficultWordParts + prefs[keys.phraseBreathingRoomMs] = config.phraseBreathingRoomMs prefs[keys.complexWordHoldMs] = config.complexWordHoldMs prefs[keys.complexWordThreshold] = config.complexWordThreshold } @@ -418,6 +421,14 @@ internal class RsvpConfigPreferenceCodec(private val keys: PrefKeys, private val copy( useAdaptiveTiming = prefs.readOrDefault(keys.useAdaptiveTiming, defaults.useAdaptiveTiming), + difficultWordSupport = (prefs[keys.difficultWordSupport]?.takeIf { it.isFinite() } ?: defaults.difficultWordSupport) + .coerceIn(0.0, Constraints.MAX_DIFFICULT_WORD_SUPPORT), + showDifficultWordParts = prefs[keys.showDifficultWordParts] + ?: (defaults.showDifficultWordParts && prefs[keys.difficultWordSupport] != 0.0), + phraseBreathingRoomMs = ( + prefs[keys.phraseBreathingRoomMs] ?: prefs[keys.adaptiveDifficultyMaxHoldMs] + ?: defaults.phraseBreathingRoomMs + ).coerceIn(0L, Constraints.MAX_ADAPTIVE_HOLD_MS), adaptiveDifficultyMaxHoldMs = prefs.readOrDefault( keys.adaptiveDifficultyMaxHoldMs, diff --git a/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt b/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt index 20797fe4..a185d3d0 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt @@ -154,6 +154,9 @@ internal class RsvpProfileJsonCodec(private val onMalformed: (Throwable) -> Unit private fun JSONObject.putAdaptiveTiming(config: RsvpConfig) { put("useAdaptiveTiming", config.useAdaptiveTiming) put("adaptiveDifficultyMaxHoldMs", config.adaptiveDifficultyMaxHoldMs) + put("difficultWordSupport", config.difficultWordSupport) + put("showDifficultWordParts", config.showDifficultWordParts) + put("phraseBreathingRoomMs", config.phraseBreathingRoomMs) put("complexWordHoldMs", config.complexWordHoldMs) put("complexWordThreshold", config.complexWordThreshold) } @@ -367,6 +370,19 @@ internal class RsvpProfileJsonCodec(private val onMalformed: (Throwable) -> Unit ): RsvpConfig = copy( useAdaptiveTiming = obj.optBoolean("useAdaptiveTiming", defaults.useAdaptiveTiming), + difficultWordSupport = ( + obj.optDouble("difficultWordSupport", defaults.difficultWordSupport) + .takeIf { it.isFinite() } ?: defaults.difficultWordSupport + ) + .coerceIn(0.0, RsvpConfigConstraints.MAX_DIFFICULT_WORD_SUPPORT), + showDifficultWordParts = obj.optBoolean( + "showDifficultWordParts", + defaults.showDifficultWordParts && obj.optDouble("difficultWordSupport", defaults.difficultWordSupport) != 0.0, + ), + phraseBreathingRoomMs = obj.optLong( + "phraseBreathingRoomMs", + obj.optLong("adaptiveDifficultyMaxHoldMs", defaults.phraseBreathingRoomMs), + ).coerceIn(0L, RsvpConfigConstraints.MAX_ADAPTIVE_HOLD_MS), adaptiveDifficultyMaxHoldMs = obj.optLong( "adaptiveDifficultyMaxHoldMs", diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpLayout.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpLayout.kt index bc7ca577..cbf06ea6 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpLayout.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpLayout.kt @@ -160,6 +160,7 @@ internal fun OrpAlignedTextLayout( highlightStart = display.content.highlightStart, highlightEndExclusive = display.content.highlightEndExclusive, highlightColor = colors.highlightColor, + additionalHighlights = display.content.additionalHighlights, pivotHighlightVisible = layout.pivotHighlightVisible, ) } @@ -278,6 +279,7 @@ private fun resolveOrpDisplay( highlightStart = content.highlightStart, highlightEndExclusive = content.highlightEndExclusive, highlightColor = colors.highlightColor, + additionalHighlights = content.additionalHighlights, pivotHighlightVisible = pivotHighlightVisible, ) val baseMeasured = @@ -323,6 +325,7 @@ private fun resolveOrpDisplay( highlightStart = windowed.highlightStart, highlightEndExclusive = windowed.highlightEndExclusive, highlightColor = colors.highlightColor, + additionalHighlights = windowed.additionalHighlights, pivotHighlightVisible = pivotHighlightVisible, ) val windowedMeasured = @@ -423,6 +426,11 @@ private fun buildWindowedContent( wordCount = content.wordCount, highlightStart = highlightStart, highlightEndExclusive = highlightEndExclusive, + additionalHighlights = content.additionalHighlights.mapNotNull { range -> + val start = maxOf(range.first, window.start) + val end = minOf(range.last + 1, window.endExclusive) + if (end > start) (prefixOffset + start - window.start) until (prefixOffset + end - window.start) else null + }, ) } diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt index 423d1b4b..64fa3653 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType import kotlin.math.roundToInt @@ -42,6 +43,7 @@ internal fun buildOrpAnnotatedText( highlightEndExclusive: Int, highlightColor: Color, pivotHighlightVisible: Boolean = true, + additionalHighlights: List = emptyList(), ): AnnotatedString = buildAnnotatedString { append(fullText) @@ -50,12 +52,17 @@ internal fun buildOrpAnnotatedText( val safeEnd = highlightEndExclusive.coerceIn(safeStart, fullText.length) if (safeEnd > safeStart) { addStyle( - style = SpanStyle(color = highlightColor), + style = SpanStyle(color = highlightColor, textDecoration = TextDecoration.Underline), start = safeStart, end = safeEnd, ) } } + additionalHighlights.forEach { range -> + val start = range.first.coerceIn(0, fullText.length) + val end = (range.last + 1).coerceIn(start, fullText.length) + if (end > start) addStyle(SpanStyle(color = highlightColor, textDecoration = TextDecoration.Underline), start, end) + } if (pivotHighlightVisible && fullText.isNotEmpty()) { val safeIndex = pivotPosition.coerceIn(0, fullText.lastIndex) addStyle( @@ -102,6 +109,7 @@ internal fun buildOrpTextContent( wordCount = wordCount, highlightStart = state.highlightStart, highlightEndExclusive = state.highlightEndExclusive, + additionalHighlights = state.additionalHighlights.toList(), ) } @@ -130,6 +138,14 @@ private fun appendWord( state.highlightEndExclusive = safeEnd } } + } else { + val highlightStart = token.highlightStart + val highlightEnd = token.highlightEndExclusive + if (highlightStart != null && highlightEnd != null) { + val safeStart = highlightStart.coerceIn(0, token.text.length) + val safeEnd = highlightEnd.coerceIn(safeStart, token.text.length) + if (safeEnd > safeStart) state.additionalHighlights += (start + safeStart) until (start + safeEnd) + } } state.needsSpace = true } diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpTypes.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpTypes.kt index 3b40d861..c7eba00a 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpTypes.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpTypes.kt @@ -28,6 +28,7 @@ internal data class OrpTextContent( val wordCount: Int, val highlightStart: Int, val highlightEndExclusive: Int, + val additionalHighlights: List = emptyList(), ) internal data class OrpBounds( @@ -50,4 +51,5 @@ internal data class OrpTextBuildState( var firstWordEndExclusive: Int = INVALID_INDEX, var highlightStart: Int = INVALID_INDEX, var highlightEndExclusive: Int = INVALID_INDEX, + val additionalHighlights: MutableList = mutableListOf(), ) diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt index e12cd067..132a1b0a 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt @@ -42,6 +42,7 @@ import com.kairo.reader.ui.bionic.BionicReadingText import com.kairo.reader.ui.rememberWindowContainerMetrics import com.kairo.reader.ui.theme.composeFontFamily import com.kairo.reader.ui.theme.readingColor +import com.kairo.reader.ui.theme.rsvpAccentColors import com.kairo.reader.ui.tutorial.StartingTutorialOverlay import com.kairo.reader.ui.tutorial.StartingTutorialOverlayState import com.kairo.reader.ui.tutorial.StartingTutorialTargetIds @@ -255,11 +256,12 @@ internal fun rememberRsvpTextColors( val pivotLineAlpha = (PIVOT_LINE_ALPHA_BASE * clampedBrightness * guideBrightness) .coerceIn(PIVOT_LINE_ALPHA_MIN, PIVOT_LINE_ALPHA_MAX) + val accents = MaterialTheme.colorScheme.rsvpAccentColors() return OrpColors( - pivotColor = MaterialTheme.colorScheme.primary, + pivotColor = accents.pivot, pivotLineColor = MaterialTheme.colorScheme.onBackground.copy(alpha = pivotLineAlpha), textColor = readingColor(MaterialTheme.colorScheme.onBackground.copy(alpha = clampedBrightness)), - highlightColor = MaterialTheme.colorScheme.primary, + highlightColor = accents.wordPart, ) } diff --git a/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt b/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt index a7d64545..d059a705 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt @@ -78,92 +78,78 @@ internal fun RsvpAdvancedSettingsContent( config.subwordChunkPauseMs, ), ) { - DeferredSliderRow( - title = stringResource(R.string.rsvp_long_word_threshold_title), - valueLabel = { resources.getString(R.string.format_chars, it.toInt()) }, - rawValue = config.longWordChars.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - longWordChars = - newValue.toInt().coerceIn( - Constraints.MIN_LONG_WORD_CHARS, - Constraints.MAX_LONG_WORD_CHARS, - ), - ) - } - }, - valueRange = - Constraints.MIN_LONG_WORD_CHARS.toFloat()..Constraints.MAX_LONG_WORD_CHARS.toFloat(), + SettingsSwitchRow( + title = stringResource(R.string.rsvp_word_parts_title), + subtitle = stringResource(R.string.rsvp_word_parts_subtitle), + checked = config.showDifficultWordParts, + onCheckedChange = { enabled -> updateConfig { it.copy(showDifficultWordParts = enabled) } }, ) DeferredSliderRow( - title = stringResource(R.string.rsvp_split_word_pause_title), - subtitle = stringResource(R.string.rsvp_split_word_pause_subtitle), - valueLabel = { resources.getString(R.string.format_plus_ms, it.toLong()) }, - rawValue = config.subwordChunkPauseMs.toFloat(), + title = stringResource(R.string.rsvp_min_word_time_title), + subtitle = stringResource(R.string.rsvp_min_word_time_subtitle), + valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, + rawValue = config.minWordMs.toFloat(), onCommit = { newValue -> updateConfig { it.copy( - subwordChunkPauseMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_SUBWORD_PAUSE_MS), + minWordMs = + newValue.toLong().coerceIn(Constraints.MIN_WORD_MS, Constraints.MAX_WORD_MS), ) } }, - valueRange = 0f..Constraints.MAX_SUBWORD_PAUSE_MS.toFloat(), + valueRange = Constraints.MIN_WORD_MS.toFloat()..Constraints.MAX_WORD_MS.toFloat(), ) - } - - ExpandableSettingsSection( - title = stringResource(R.string.rsvp_difficulty_model_title), - summary = - stringResource( - R.string.rsvp_difficulty_model_summary, - config.syllableExtraMs, - config.rarityExtraMaxMs, - formatPercent(resources, config.complexityStrength), - ), - ) { DeferredSliderRow( - title = stringResource(R.string.rsvp_syllable_boost_title), - valueLabel = { resources.getString(R.string.format_plus_ms, it.toLong()) }, - rawValue = config.syllableExtraMs.toFloat(), + title = stringResource(R.string.rsvp_long_word_min_title), + subtitle = stringResource(R.string.rsvp_long_word_min_subtitle), + valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, + rawValue = config.longWordMinMs.toFloat(), onCommit = { newValue -> updateConfig { it.copy( - syllableExtraMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_SYLLABLE_EXTRA_MS), + longWordMinMs = + newValue.toLong().coerceIn( + Constraints.MIN_LONG_WORD_MS, + Constraints.MAX_LONG_WORD_MS, + ), ) } }, - valueRange = 0f..Constraints.MAX_SYLLABLE_EXTRA_MS.toFloat(), + valueRange = + Constraints.MIN_LONG_WORD_MS.toFloat()..Constraints.MAX_LONG_WORD_MS.toFloat(), ) DeferredSliderRow( - title = stringResource(R.string.rsvp_rarity_boost_title), - valueLabel = { resources.getString(R.string.format_plus_ms, it.toLong()) }, - rawValue = config.rarityExtraMaxMs.toFloat(), + title = stringResource(R.string.rsvp_long_word_threshold_title), + valueLabel = { resources.getString(R.string.format_chars, it.toInt()) }, + rawValue = config.longWordChars.toFloat(), onCommit = { newValue -> updateConfig { it.copy( - rarityExtraMaxMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_RARITY_EXTRA_MS), + longWordChars = + newValue.toInt().coerceIn( + Constraints.MIN_LONG_WORD_CHARS, + Constraints.MAX_LONG_WORD_CHARS, + ), ) } }, - valueRange = 0f..Constraints.MAX_RARITY_EXTRA_MS.toFloat(), + valueRange = + Constraints.MIN_LONG_WORD_CHARS.toFloat()..Constraints.MAX_LONG_WORD_CHARS.toFloat(), ) DeferredSliderRow( - title = stringResource(R.string.rsvp_complexity_strength_title), - valueLabel = { resources.getString(R.string.format_percent, it.toInt()) }, - rawValue = (config.complexityStrength * Constraints.PERCENT_SCALE).toFloat(), + title = stringResource(R.string.rsvp_split_word_pause_title), + subtitle = stringResource(R.string.rsvp_split_word_pause_subtitle), + valueLabel = { resources.getString(R.string.format_plus_ms, it.toLong()) }, + rawValue = config.subwordChunkPauseMs.toFloat(), onCommit = { newValue -> updateConfig { it.copy( - complexityStrength = - (newValue / Constraints.PERCENT_SCALE).coerceIn(0.0, 1.0), + subwordChunkPauseMs = + newValue.toLong().coerceIn(0L, Constraints.MAX_SUBWORD_PAUSE_MS), ) } }, - valueRange = 0f..Constraints.PERCENT_SCALE.toFloat(), + valueRange = 0f..Constraints.MAX_SUBWORD_PAUSE_MS.toFloat(), ) } @@ -226,6 +212,16 @@ internal fun RsvpAdvancedSettingsContent( }, valueRange = 0f..Constraints.MAX_PERIOD_PAUSE_MS.toFloat(), ) + DeferredSliderRow( + title = stringResource(R.string.rsvp_sentence_end_pause_title), + subtitle = stringResource(R.string.rsvp_sentence_end_pause_subtitle), + valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, + rawValue = config.sentenceEndPauseMs.toFloat(), + onCommit = { value -> + updateConfig { it.copy(sentenceEndPauseMs = value.toLong().coerceIn(0L, Constraints.MAX_SENTENCE_END_PAUSE_MS)) } + }, + valueRange = 0f..Constraints.MAX_SENTENCE_END_PAUSE_MS.toFloat(), + ) DeferredSliderRow( title = stringResource(R.string.rsvp_punctuation_dash), valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, @@ -523,67 +519,6 @@ internal fun RsvpAdvancedSettingsContent( } } - ExpandableSettingsSection( - title = stringResource(R.string.rsvp_adaptive_pacing_title), - summary = - stringResource( - R.string.rsvp_adaptive_pacing_summary, - config.adaptiveDifficultyMaxHoldMs, - config.complexWordHoldMs, - formatMultiplier(resources, config.complexWordThreshold), - ), - ) { - DeferredSliderRow( - title = stringResource(R.string.rsvp_difficulty_boost_title), - subtitle = stringResource(R.string.rsvp_difficulty_boost_subtitle), - valueLabel = { resources.getString(R.string.format_plus_ms, it.toLong()) }, - rawValue = config.adaptiveDifficultyMaxHoldMs.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - adaptiveDifficultyMaxHoldMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_ADAPTIVE_HOLD_MS), - ) - } - }, - valueRange = 0f..Constraints.MAX_ADAPTIVE_HOLD_MS.toFloat(), - ) - DeferredSliderRow( - title = stringResource(R.string.rsvp_complex_word_boost_title), - subtitle = stringResource(R.string.rsvp_complex_word_boost_subtitle), - valueLabel = { resources.getString(R.string.format_plus_ms, it.toLong()) }, - rawValue = config.complexWordHoldMs.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - complexWordHoldMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_ADAPTIVE_HOLD_MS), - ) - } - }, - valueRange = 0f..Constraints.MAX_ADAPTIVE_HOLD_MS.toFloat(), - ) - DeferredSliderRow( - title = stringResource(R.string.rsvp_complex_word_threshold_title), - subtitle = stringResource(R.string.rsvp_complex_word_threshold_subtitle), - valueLabel = { resources.getString(R.string.format_multiplier, it) }, - rawValue = config.complexWordThreshold.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - complexWordThreshold = - newValue.toDouble().coerceIn( - Constraints.MIN_COMPLEX_WORD_THRESHOLD, - Constraints.MAX_COMPLEX_WORD_THRESHOLD, - ), - ) - } - }, - valueRange = - Constraints.MIN_COMPLEX_WORD_THRESHOLD.toFloat()..Constraints.MAX_COMPLEX_WORD_THRESHOLD.toFloat(), - ) - } - ExpandableSettingsSection( title = stringResource(R.string.rsvp_rhythm_title), summary = @@ -594,6 +529,12 @@ internal fun RsvpAdvancedSettingsContent( stringResource(blinkModeLabelRes(config.blinkMode)), ), ) { + SettingsSwitchRow( + title = stringResource(R.string.rsvp_adaptive_pacing_title), + subtitle = stringResource(R.string.rsvp_adaptive_pacing_subtitle), + checked = config.useAdaptiveTiming, + onCheckedChange = { enabled -> updateConfig { it.copy(useAdaptiveTiming = enabled) } }, + ) DeferredSliderRow( title = stringResource(R.string.rsvp_stability_title), subtitle = stringResource(R.string.rsvp_stability_subtitle), diff --git a/app/src/main/java/com/kairo/reader/ui/settings/RsvpEssentialSettingsContent.kt b/app/src/main/java/com/kairo/reader/ui/settings/RsvpEssentialSettingsContent.kt index 9b13ff17..b80f1e16 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/RsvpEssentialSettingsContent.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/RsvpEssentialSettingsContent.kt @@ -87,67 +87,26 @@ internal fun RsvpEssentialSettingsContent( }, valueRange = RsvpSpeedControl.MIN_SPEED..RsvpSpeedControl.MAX_SPEED, ) - ExpandableSettingsSection( - title = stringResource(R.string.rsvp_readability_floors_title), - summary = stringResource(R.string.rsvp_quick_tune_subtitle), - ) { - DeferredSliderRow( - title = stringResource(R.string.rsvp_min_word_time_title), - subtitle = stringResource(R.string.rsvp_min_word_time_subtitle), - valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, - rawValue = config.minWordMs.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - minWordMs = - newValue.toLong().coerceIn(Constraints.MIN_WORD_MS, Constraints.MAX_WORD_MS), - ) - } - }, - valueRange = Constraints.MIN_WORD_MS.toFloat()..Constraints.MAX_WORD_MS.toFloat(), - ) - DeferredSliderRow( - title = stringResource(R.string.rsvp_long_word_min_title), - subtitle = stringResource(R.string.rsvp_long_word_min_subtitle), - valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, - rawValue = config.longWordMinMs.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - longWordMinMs = - newValue.toLong().coerceIn( - Constraints.MIN_LONG_WORD_MS, - Constraints.MAX_LONG_WORD_MS, - ), - ) - } - }, - valueRange = - Constraints.MIN_LONG_WORD_MS.toFloat()..Constraints.MAX_LONG_WORD_MS.toFloat(), - ) - DeferredSliderRow( - title = stringResource(R.string.rsvp_sentence_end_pause_title), - subtitle = stringResource(R.string.rsvp_sentence_end_pause_subtitle), - valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, - rawValue = config.sentenceEndPauseMs.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - sentenceEndPauseMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_SENTENCE_END_PAUSE_MS), - ) - } - }, - valueRange = 0f..Constraints.MAX_SENTENCE_END_PAUSE_MS.toFloat(), - ) - } - SettingsSwitchRow( - title = stringResource(R.string.rsvp_adaptive_pacing_title), - subtitle = stringResource(R.string.rsvp_adaptive_pacing_subtitle), - checked = config.useAdaptiveTiming, - onCheckedChange = { enabled -> - updateConfig { it.copy(useAdaptiveTiming = enabled) } + DeferredSliderRow( + title = stringResource(R.string.rsvp_difficult_word_support_title), + subtitle = stringResource(R.string.rsvp_difficult_word_support_subtitle), + valueLabel = { + when (it.toInt()) { + 0 -> resources.getString(R.string.rsvp_reading_support_off) + 200 -> resources.getString(R.string.rsvp_reading_support_strong) + else -> resources.getString(R.string.format_percent, it.toInt()) + } + }, + rawValue = (config.difficultWordSupport * Constraints.PERCENT_SCALE).toFloat(), + onCommit = { value -> + updateConfig { + it.copy( + difficultWordSupport = (value / Constraints.PERCENT_SCALE) + .coerceIn(0.0, Constraints.MAX_DIFFICULT_WORD_SUPPORT) + ) + } }, + valueRange = 0f..(Constraints.MAX_DIFFICULT_WORD_SUPPORT * Constraints.PERCENT_SCALE).toFloat(), ) } diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt index 3694cfc6..8f0245b9 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt @@ -3,6 +3,21 @@ package com.kairo.reader.ui.settings import com.kairo.reader.R internal val rsvpSearchEntries = listOf( + SettingsSearchEntry( + id = "rsvp.rsvp_difficult_word_support_title", + page = SettingsSearchPage.RSVP, + titleRes = R.string.rsvp_difficult_word_support_title, + descriptionRes = R.string.rsvp_difficult_word_support_subtitle, + sectionRes = R.string.rsvp_quick_tune_title, + ), + SettingsSearchEntry( + id = "rsvp.rsvp_word_parts_title", + page = SettingsSearchPage.RSVP, + titleRes = R.string.rsvp_word_parts_title, + descriptionRes = R.string.rsvp_word_parts_subtitle, + sectionRes = R.string.rsvp_readability_floors_title, + advanced = true, + ), SettingsSearchEntry( id = "rsvp.rsvp_reading_speed_title", page = SettingsSearchPage.RSVP, @@ -16,6 +31,7 @@ internal val rsvpSearchEntries = listOf( titleRes = R.string.rsvp_min_word_time_title, descriptionRes = R.string.rsvp_min_word_time_subtitle, sectionRes = R.string.rsvp_readability_floors_title, + advanced = true, ), SettingsSearchEntry( id = "rsvp.rsvp_long_word_min_title", @@ -23,20 +39,23 @@ internal val rsvpSearchEntries = listOf( titleRes = R.string.rsvp_long_word_min_title, descriptionRes = R.string.rsvp_long_word_min_subtitle, sectionRes = R.string.rsvp_readability_floors_title, + advanced = true, ), SettingsSearchEntry( id = "rsvp.rsvp_sentence_end_pause_title", page = SettingsSearchPage.RSVP, titleRes = R.string.rsvp_sentence_end_pause_title, descriptionRes = R.string.rsvp_sentence_end_pause_subtitle, - sectionRes = R.string.rsvp_readability_floors_title, + sectionRes = R.string.rsvp_punctuation_pauses_title, + advanced = true, ), SettingsSearchEntry( id = "rsvp.rsvp_adaptive_pacing_title", page = SettingsSearchPage.RSVP, titleRes = R.string.rsvp_adaptive_pacing_title, descriptionRes = R.string.rsvp_adaptive_pacing_subtitle, - sectionRes = R.string.rsvp_readability_floors_title, + sectionRes = R.string.rsvp_rhythm_title, + advanced = true, ), SettingsSearchEntry( id = "rsvp.rsvp_phrase_chunking_title", @@ -128,30 +147,6 @@ internal val rsvpSearchEntries = listOf( sectionRes = R.string.rsvp_readability_floors_title, advanced = true, ), - SettingsSearchEntry( - id = "rsvp.rsvp_syllable_boost_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_syllable_boost_title, - descriptionRes = R.string.settings_search_syllables_description, - sectionRes = R.string.rsvp_difficulty_model_title, - advanced = true, - ), - SettingsSearchEntry( - id = "rsvp.rsvp_rarity_boost_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_rarity_boost_title, - descriptionRes = R.string.settings_search_rarity_description, - sectionRes = R.string.rsvp_difficulty_model_title, - advanced = true, - ), - SettingsSearchEntry( - id = "rsvp.rsvp_complexity_strength_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_complexity_strength_title, - descriptionRes = R.string.settings_search_complexity_description, - sectionRes = R.string.rsvp_difficulty_model_title, - advanced = true, - ), SettingsSearchEntry( id = "rsvp.rsvp_punctuation_breathing_title", page = SettingsSearchPage.RSVP, @@ -297,30 +292,6 @@ internal val rsvpSearchEntries = listOf( advanced = true, requiresRes = R.string.rsvp_parenthetical_aside_title, ), - SettingsSearchEntry( - id = "rsvp.rsvp_difficulty_boost_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_difficulty_boost_title, - descriptionRes = R.string.rsvp_difficulty_boost_subtitle, - sectionRes = R.string.rsvp_adaptive_pacing_title, - advanced = true, - ), - SettingsSearchEntry( - id = "rsvp.rsvp_complex_word_boost_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_complex_word_boost_title, - descriptionRes = R.string.rsvp_complex_word_boost_subtitle, - sectionRes = R.string.rsvp_adaptive_pacing_title, - advanced = true, - ), - SettingsSearchEntry( - id = "rsvp.rsvp_complex_word_threshold_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_complex_word_threshold_title, - descriptionRes = R.string.rsvp_complex_word_threshold_subtitle, - sectionRes = R.string.rsvp_adaptive_pacing_title, - advanced = true, - ), SettingsSearchEntry( id = "rsvp.rsvp_stability_title", page = SettingsSearchPage.RSVP, diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt index d15ef4b2..03384b93 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt @@ -148,7 +148,16 @@ private fun ThemeRsvpPreview(preferences: UserPreferences, design: ThemeDesign) Column(Modifier.fillMaxWidth().heightIn(min = 112.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(stringResource(R.string.theme_preview_paused), style = MaterialTheme.typography.labelSmall) OrpAlignedText( - tokens = listOf(Token(sample, TokenType.WORD, orpIndex = (sample.length / 3).coerceAtMost(sample.lastIndex))), + tokens = listOf( + Token( + sample, + TokenType.WORD, + orpIndex = (sample.length / ORP_PREVIEW_PIVOT_DIVISOR).coerceAtMost(sample.lastIndex), + isSubwordChunk = true, + highlightStart = (sample.length / WORD_PART_PREVIEW_START_DIVISOR).coerceAtMost(sample.lastIndex), + highlightEndExclusive = sample.length, + ) + ), typography = OrpTypography( preferences.rsvpFontSizeSp, design.timedFont.composeFontFamily(), @@ -196,3 +205,5 @@ private fun ThemeInterfacePreview() { } private val CircleShapeForPreview = RoundedCornerShape(50) +private const val ORP_PREVIEW_PIVOT_DIVISOR = 3 +private const val WORD_PART_PREVIEW_START_DIVISOR = 2 diff --git a/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt b/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt new file mode 100644 index 00000000..8fe48819 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt @@ -0,0 +1,101 @@ +package com.kairo.reader.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +internal data class RsvpAccentColors(val pivot: Color, val wordPart: Color) + +/** Tertiary is usually the palette's hue-separated accent; reserve primary for word parts. */ +internal fun ColorScheme.rsvpAccentColors(): RsvpAccentColors { + val themedPivot = contrastingColor(tertiary, listOf(background)) + val pivot = if (colorChroma(background) >= MIN_BACKGROUND_CHROMA && + hueDistance(background, themedPivot) < MIN_PIVOT_HUE_DISTANCE + ) { + contrastingColor( + Color.hsl( + (colorHue(background) + HUE_HALF_TURN) % FULL_CIRCLE_DEGREES, + PIVOT_SATURATION, + accentLightness(background), + ), + listOf(background), + ) + } else { + themedPivot + } + val themedWordPart = contrastingColor(primary, listOf(background)) + val wordPart = if (channelDistance(pivot, themedWordPart) >= MIN_ACCENT_DISTANCE) { + themedWordPart + } else { + val hue = colorHue(pivot) + val lightness = accentLightness(background) + val derived = DISTINCT_HUE_OFFSETS.map { offset -> + contrastingColor( + Color.hsl((hue + offset) % FULL_CIRCLE_DEGREES, WORD_PART_SATURATION, lightness), + listOf(background), + ) + } + val best = derived.maxBy { channelDistance(pivot, it) } + if (channelDistance(pivot, best) >= MIN_ACCENT_DISTANCE) { + best + } else { + val extremes = listOf(Color.Black, Color.White).filter { colorContrast(it, background) >= MIN_TEXT_CONTRAST } + (derived + extremes).maxBy { channelDistance(pivot, it) } + } + } + return RsvpAccentColors(pivot, wordPart) +} + +private fun channelDistance(first: Color, second: Color): Float = + max(abs(first.red - second.red), max(abs(first.green - second.green), abs(first.blue - second.blue))) + +private fun accentLightness(background: Color): Float = + if (colorContrast(Color.White, background) > colorContrast(Color.Black, background)) { + DARK_BACKGROUND_ACCENT_LIGHTNESS + } else { + LIGHT_BACKGROUND_ACCENT_LIGHTNESS + } + +private fun colorChroma(color: Color): Float = + max(color.red, max(color.green, color.blue)) - min(color.red, min(color.green, color.blue)) + +private fun hueDistance(first: Color, second: Color): Float { + val distance = abs(colorHue(first) - colorHue(second)) + return min(distance, FULL_CIRCLE_DEGREES - distance) +} + +private fun colorHue(color: Color): Float { + val high = max(color.red, max(color.green, color.blue)) + val low = min(color.red, min(color.green, color.blue)) + val delta = high - low + if (delta < NEUTRAL_CHROMA_THRESHOLD) return NEUTRAL_HUE_DEGREES + val sector = when (high) { + color.red -> (color.green - color.blue) / delta + color.green -> (color.blue - color.red) / delta + GREEN_HUE_SECTOR + else -> (color.red - color.green) / delta + BLUE_HUE_SECTOR + } + return (sector * HUE_SECTOR_DEGREES + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES +} + +private const val MIN_ACCENT_DISTANCE = 0.16f +private const val MIN_BACKGROUND_CHROMA = 0.04f +private const val MIN_PIVOT_HUE_DISTANCE = 45f +private const val MIN_TEXT_CONTRAST = 4.5f +private const val WORD_PART_SATURATION = 0.9f +private const val PIVOT_SATURATION = 0.8f +private const val LIGHT_BACKGROUND_ACCENT_LIGHTNESS = 0.38f +private const val DARK_BACKGROUND_ACCENT_LIGHTNESS = 0.72f +private const val NEUTRAL_CHROMA_THRESHOLD = 0.02f +private const val NEUTRAL_HUE_DEGREES = 40f +private const val GREEN_HUE_SECTOR = 2f +private const val BLUE_HUE_SECTOR = 4f +private const val HUE_SECTOR_DEGREES = 60f +private const val FULL_CIRCLE_DEGREES = 360f +private const val HUE_HALF_TURN = 180f +private const val HUE_QUARTER_TURN = 90f +private const val HUE_WARM_TURN = 150f +private const val HUE_COOL_TURN = 210f +private const val HUE_THREE_QUARTER_TURN = 270f +private val DISTINCT_HUE_OFFSETS = listOf(HUE_QUARTER_TURN, HUE_WARM_TURN, HUE_COOL_TURN, HUE_THREE_QUARTER_TURN) diff --git a/app/src/main/res/values-de/settings_search.xml b/app/src/main/res/values-de/settings_search.xml index e5e3a380..b64ead62 100644 --- a/app/src/main/res/values-de/settings_search.xml +++ b/app/src/main/res/values-de/settings_search.xml @@ -8,9 +8,6 @@ Größe des beim Lesen angezeigten Textes. Farben und ein helles oder dunkles Lesedesign auswählen. Wortlänge, ab der die Mindestzeit für lange Wörter gilt. - Zusätzliche Lesezeit für Wörter mit mehr Silben. - Zusätzliche Lesezeit für seltenere Wörter. - Festlegen, wie stark die Wortschwierigkeit die Lesezeit beeinflusst. Das Tempo für Text in Klammern anpassen. Die Lesegeschwindigkeit für Dialoge anpassen. Den RSVP-Text auf dem Bildschirm nach oben oder unten verschieben. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 900747ea..2ce236cb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -128,9 +128,8 @@ Zusatzzeit für lange oder technische Wörter. Pause am Satzende Atempause an Satzgrenzen. - Adaptives Tempo - Fügt extra Halt für schwierige Wörter und Klauseln hinzu. - Schwierigkeit +%1$d ms | Komplexität +%2$d ms | Schwelle %3$s + Automatischer Rhythmus + Passt Satzübergänge und Satzenden an. Die Leseunterstützung funktioniert unabhängig davon. Anzeige Beeinflusst nur die Schnellleseansicht. Geschwindigkeitsgrenzen @@ -139,16 +138,11 @@ Extreme Geschwindigkeiten freischalten Erlaubt bis %1$d ms (kann unlesbar werden). Erlaubt sehr hohe Geschwindigkeiten (kann schnell unlesbar werden). - Lesbarkeitsgrenzen + Mindestanzeigezeiten Schwelle %1$d Zeichen | Aufteilung +%2$d ms Schwelle für lange Wörter Pause zwischen Segmenten Extra Zeit zwischen Segmenten langer Wörter. - Schwierigkeitsmodell - Silben +%1$d ms | Seltenheit +%2$d ms | Stärke %3$s - Silben-Boost - Seltenheits-Boost - Komplexitätsstärke Interpunktionspausen Atmung %1$s | Komma %2$d ms | Punkt %3$d ms | Absatz %4$d ms | Seite %5$s Komma @@ -170,12 +164,6 @@ Klammern %1$s | Dialog %2$s Klammern Dialogtempo - Schwierigkeits-Boost - Maximale Zusatzzeit für schwierige Wörter. - Komplexe Wörter-Boost - Extra Halt für Wörter über der Komplexitätsschwelle. - Schwelle für komplexe Wörter - Niedriger = mehr Wörter gelten als komplex. Rhythmus Stabilität %1$s | Klausel %2$s | Trennung %3$s Stabilität @@ -641,4 +629,10 @@ Zeigt vorherige und kommende Wörter über Teilsatz- und Satzgrenzen hinweg in der Lesezeile. Beginnt bei einem neuen Absatz neu. Zeigt umgebende Wörter des aktuellen Teilsatzes, soweit Platz ist. Das Hauptwort bleibt fest. Pausiere, um den Teilsatz anzusehen. Zeigt nur den Hauptlesetext. + Leseunterstützung + Mehr Zeit für Wörter und Phrasen; bei Englisch zählt auch die Vertrautheit mit Wörtern. + Wortteil-Hilfe + Hebt Teile ausgewählter langer englischer Wörter nacheinander hervor. Die zusätzliche Zeit wird separat eingestellt. + Aus + Stark diff --git a/app/src/main/res/values-es/settings_search.xml b/app/src/main/res/values-es/settings_search.xml index ff0f486b..1fd23c86 100644 --- a/app/src/main/res/values-es/settings_search.xml +++ b/app/src/main/res/values-es/settings_search.xml @@ -8,9 +8,6 @@ Tamaño del texto que se muestra al leer. Elige los colores y un tema de lectura claro u oscuro. Longitud de palabra a partir de la cual se aplica el tiempo mínimo para palabras largas. - Tiempo de lectura adicional para palabras con más sílabas. - Tiempo de lectura adicional para palabras menos frecuentes. - Ajusta cuánto influye la dificultad de las palabras en el tiempo de lectura. Ajusta el ritmo del texto entre paréntesis. Ajusta la velocidad de lectura de los diálogos. Mueve el texto RSVP hacia arriba o abajo en la pantalla. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8d61c419..70333acc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -128,9 +128,8 @@ Tiempo extra para palabras largas o técnicas. Pausa al final de frase Respiro en los límites de las oraciones. - Ritmo adaptativo - Añade retención extra en palabras y cláusulas difíciles. - Dificultad +%1$d ms | Complejidad +%2$d ms | Umbral %3$s + Ritmo automático + Ajusta las transiciones y los finales de oración. El apoyo a la lectura funciona de forma independiente. Visualización Solo afecta a la pantalla de lectura rápida. Límites de velocidad @@ -139,16 +138,11 @@ Desbloquear velocidades extremas Permite hasta %1$d ms (puede ser ilegible). Permite velocidades muy altas (pueden volverse ilegibles rápidamente). - Umbrales de legibilidad + Tiempos mínimos de visualización Umbral %1$d caracteres | División +%2$d ms Umbral de palabra larga Pausa entre fragmentos Tiempo extra entre fragmentos de palabras largas. - Modelo de dificultad - Sílabas +%1$d ms | Rareza +%2$d ms | Intensidad %3$s - Impulso por sílabas - Impulso por rareza - Intensidad de complejidad Pausas de puntuación Respiración %1$s | Coma %2$d ms | Punto %3$d ms | Párrafo %4$d ms | Página %5$s Coma @@ -170,12 +164,6 @@ Paréntesis %1$s | Diálogo %2$s Paréntesis Ritmo de diálogo - Impulso de dificultad - Máxima retención extra para palabras difíciles. - Impulso de palabras complejas - Retención extra para palabras sobre el umbral de complejidad. - Umbral de palabra compleja - Más bajo = más palabras consideradas complejas. Ritmo Estabilidad %1$s | Cláusula %2$s | Separación %3$s Estabilidad @@ -647,4 +635,10 @@ Muestra palabras anteriores y siguientes entre cláusulas y oraciones en la línea de lectura. Se reinicia al comenzar un párrafo. Muestra palabras alrededor de la actual dentro de la cláusula si hay espacio, sin mover la palabra principal. Pausa para revisar la cláusula. Muestra solo el texto principal de lectura. + Apoyo a la lectura + Más tiempo para palabras y frases; en inglés también se considera la familiaridad con cada palabra. + Guía por partes + Resalta por partes algunas palabras largas en inglés. El tiempo adicional se ajusta por separado. + Desactivado + Intenso diff --git a/app/src/main/res/values-fr/settings_search.xml b/app/src/main/res/values-fr/settings_search.xml index 184a0086..9b910724 100644 --- a/app/src/main/res/values-fr/settings_search.xml +++ b/app/src/main/res/values-fr/settings_search.xml @@ -8,9 +8,6 @@ Taille du texte affiché pendant la lecture. Choisissez les couleurs et un thème de lecture clair ou sombre. Longueur à partir de laquelle le temps minimum des mots longs s’applique. - Temps de lecture supplémentaire pour les mots avec plus de syllabes. - Temps de lecture supplémentaire pour les mots moins courants. - Réglez l’influence de la difficulté des mots sur le temps de lecture. Ajustez le rythme du texte entre parenthèses. Ajustez la vitesse de lecture des dialogues. Déplacez le texte RSVP vers le haut ou le bas de l’écran. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f5ad3c43..72fafa1e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -128,9 +128,8 @@ Temps supplémentaire pour les mots longs ou techniques. Pause en fin de phrase Respiration aux limites de phrase. - Rythme adaptatif - Ajoute du temps aux mots et propositions difficiles. - Difficulté +%1$d ms | Complexité +%2$d ms | Seuil %3$s + Rythme automatique + Ajuste les transitions et les fins de phrase. L’aide à la lecture fonctionne indépendamment. Affichage Affecte uniquement l\'écran de lecture rapide. Limites de vitesse @@ -139,16 +138,11 @@ Déverrouiller les vitesses extrêmes Autorise jusqu\'à %1$d ms (peut devenir illisible). Autorise des vitesses très élevées (peut devenir rapidement illisible). - Planchers de lisibilité + Durées minimales d’affichage Seuil %1$d caractères | Découpage +%2$d ms Seuil de mot long Pause entre segments Temps supplémentaire entre segments de mots longs. - Modèle de difficulté - Syllabes +%1$d ms | Rareté +%2$d ms | Force %3$s - Boost syllabique - Boost de rareté - Force de complexité Pauses de ponctuation Respiration %1$s | Virgule %2$d ms | Point %3$d ms | Paragraphe %4$d ms | Page %5$s Virgule @@ -170,12 +164,6 @@ Parenthèses %1$s | Dialogue %2$s Parenthèses Rythme de dialogue - Boost de difficulté - Temps supplémentaire max pour les mots difficiles. - Boost de mots complexes - Temps supplémentaire pour les mots au-dessus du seuil de complexité. - Seuil de mot complexe - Plus bas = plus de mots considérés complexes. Rythme Stabilité %1$s | Proposition %2$s | Séparation %3$s Stabilité @@ -647,4 +635,10 @@ Affiche les mots précédents et suivants sur la ligne de lecture, au-delà des propositions et des phrases. Repart à zéro à chaque paragraphe. Affiche les mots autour du mot actuel dans la proposition selon la place disponible, sans déplacer le mot principal. Faites une pause pour revoir la proposition. Affiche uniquement le texte principal. + Aide à la lecture + Plus de temps pour les mots et les phrases ; en anglais, la familiarité des mots compte aussi. + Guidage par segments + Surligne successivement les segments de certains longs mots anglais. Le temps supplémentaire se règle séparément. + Désactivée + Forte diff --git a/app/src/main/res/values-ja/settings_search.xml b/app/src/main/res/values-ja/settings_search.xml index 9e6eedd5..7a2a7584 100644 --- a/app/src/main/res/values-ja/settings_search.xml +++ b/app/src/main/res/values-ja/settings_search.xml @@ -8,9 +8,6 @@ 読書中に表示する文字の大きさ。 色と、明るいまたは暗い読書テーマを選びます。 長い単語の最小表示時間を適用する文字数。 - 音節の多い単語に追加する読書時間。 - 出現頻度の低い単語に追加する読書時間。 - 単語の難しさが読書時間に与える影響を調整します。 括弧内のテキストのペースを調整します。 会話文の読書速度を調整します。 画面上のRSVPテキストを上下に移動します。 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ce537658..401398bd 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -128,9 +128,8 @@ 長い・専門的な単語に追加時間。 文末ポーズ 文の区切りに余白を追加します。 - 適応ペース - 難しい単語や節に追加の停止を加えます。 - 難度 +%1$dミリ秒 | 複雑度 +%2$dミリ秒 | しきい値 %3$s + 自動リズム + 節の切り替わりと文末の間を調整します。読書サポートは独立して動作します。 表示 速読画面のみに影響します。 速度制限 @@ -139,16 +138,11 @@ 極端な速度を解除 %1$dミリ秒 まで許可(読めなくなる可能性があります)。 非常に高い速度を許可(すぐに読めなくなる可能性があります)。 - 可読性の下限 + 最小表示時間 しきい値 %1$d 文字 | 分割 +%2$dミリ秒 長い単語のしきい値 分割間のポーズ 長い単語の分割間に追加時間。 - 難易度モデル - 音節 +%1$dミリ秒 | 希少度 +%2$dミリ秒 | 強度 %3$s - 音節ブースト - 希少度ブースト - 複雑度の強度 句読点のポーズ 余白 %1$s | カンマ %2$dミリ秒 | ピリオド %3$dミリ秒 | 段落 %4$dミリ秒 | ページ %5$s カンマ @@ -170,12 +164,6 @@ 括弧 %1$s | 会話 %2$s 括弧 会話ペース - 難易度ブースト - 難しい単語への最大追加停止。 - 複雑単語ブースト - 複雑度しきい値を超える単語に追加停止。 - 複雑単語のしきい値 - 低いほど複雑な単語に判定されます。 リズム 安定性 %1$s | 句 %2$s | 単語の区切り %3$s 安定性 @@ -635,4 +623,10 @@ 節や文の境界を越えて前後の単語を読書行に表示します。新しい段落でリセットします。 中心の単語を固定し、空きスペースに現在の節の前後の単語を表示します。一時停止すると節を確認できます。 メインの読書テキストのみ表示します。 + 読書サポート + 単語やフレーズに時間を追加します。英語では単語のなじみやすさも考慮します。 + 単語の分割表示 + 一部の長い英単語を、強調箇所を動かしながら分割表示します。追加時間は別に調整できます。 + オフ + 強 diff --git a/app/src/main/res/values-pt-rBR/settings_search.xml b/app/src/main/res/values-pt-rBR/settings_search.xml index cd48ee10..76610e00 100644 --- a/app/src/main/res/values-pt-rBR/settings_search.xml +++ b/app/src/main/res/values-pt-rBR/settings_search.xml @@ -8,9 +8,6 @@ Tamanho do texto exibido durante a leitura. Escolha as cores e um tema de leitura claro ou escuro. Comprimento a partir do qual o tempo mínimo para palavras longas se aplica. - Tempo de leitura adicional para palavras com mais sílabas. - Tempo de leitura adicional para palavras menos comuns. - Ajuste quanto a dificuldade das palavras influencia o tempo de leitura. Ajuste o ritmo do texto entre parênteses. Ajuste a velocidade de leitura dos diálogos. Mova o texto RSVP para cima ou para baixo na tela. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0a066647..cff149dc 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -128,9 +128,8 @@ Tempo extra para palavras longas ou técnicas. Pausa no fim da frase Respiro nos limites das frases. - Ritmo adaptativo - Adiciona tempo extra em palavras e cláusulas difíceis. - Dificuldade +%1$d ms | Complexidade +%2$d ms | Limite %3$s + Ritmo automático + Ajusta as transições e os finais de frase. O apoio à leitura funciona de forma independente. Exibição Afeta apenas a tela de leitura rápida. Limites de velocidade @@ -139,16 +138,11 @@ Desbloquear velocidades extremas Permite até %1$d ms (pode ficar ilegível). Permite velocidades muito altas (podem ficar ilegíveis rapidamente). - Limites de legibilidade + Tempos mínimos de exibição Limite %1$d caracteres | Divisão +%2$d ms Limite de palavra longa Pausa entre partes Tempo extra entre partes de palavras longas. - Modelo de dificuldade - Sílabas +%1$d ms | Raridade +%2$d ms | Força %3$s - Impulso de sílabas - Impulso de raridade - Força da complexidade Pausas de pontuação Respiro %1$s | Vírgula %2$d ms | Ponto %3$d ms | Parágrafo %4$d ms | Página %5$s Vírgula @@ -170,12 +164,6 @@ Parênteses %1$s | Diálogo %2$s Parênteses Ritmo de diálogo - Impulso de dificuldade - Máximo de tempo extra para palavras difíceis. - Impulso de palavras complexas - Tempo extra para palavras acima do limite de complexidade. - Limite de palavra complexa - Mais baixo = mais palavras consideradas complexas. Ritmo Estabilidade %1$s | Cláusula %2$s | Separação %3$s Estabilidade @@ -647,4 +635,10 @@ Mostra palavras anteriores e seguintes entre orações e frases na linha de leitura. Reinicia em um novo parágrafo. Mostra palavras ao redor da atual na oração quando há espaço, sem mover a palavra principal. Pause para rever a oração. Mostra apenas o texto principal da leitura. + Apoio à leitura + Mais tempo para palavras e frases; em inglês, também considera a familiaridade das palavras. + Guia por partes + Destaca em partes algumas palavras longas em inglês. O tempo extra é ajustado separadamente. + Desativado + Forte diff --git a/app/src/main/res/values-zh-rCN/settings_search.xml b/app/src/main/res/values-zh-rCN/settings_search.xml index d9fecb84..d63fa707 100644 --- a/app/src/main/res/values-zh-rCN/settings_search.xml +++ b/app/src/main/res/values-zh-rCN/settings_search.xml @@ -8,9 +8,6 @@ 阅读时显示的文字大小。 选择颜色以及浅色或深色阅读主题。 开始应用长词最短显示时间的单词长度。 - 为音节较多的单词增加阅读时间。 - 为不常见的单词增加阅读时间。 - 调整单词难度对阅读时间的影响程度。 调整括号内文字的阅读节奏。 调整对话的阅读速度。 在屏幕上向上或向下移动RSVP文字。 diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6e1114ef..0b56883e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -128,9 +128,8 @@ 为长词或专业词提供额外时间。 句末停顿 在句子边界留出空隙。 - 自适应节奏 - 为难词和从句添加额外停顿。 - 难度 +%1$d毫秒 | 复杂度 +%2$d毫秒 | 阈值 %3$s + 自动节奏 + 调整分句过渡和句末停顿。阅读辅助独立生效。 显示 只影响速读屏幕。 速度限制 @@ -139,16 +138,11 @@ 解锁极限速度 允许最低至 %1$d毫秒(可能难以阅读)。 允许非常高的速度(可能很快难以阅读)。 - 可读性下限 + 最短显示时间 阈值 %1$d 字符 | 拆分 +%2$d毫秒 长词阈值 分段停顿 长词分段之间的额外时间。 - 难度模型 - 音节 +%1$d毫秒 | 稀有度 +%2$d毫秒 | 强度 %3$s - 音节增强 - 稀有度增强 - 复杂度强度 标点停顿 呼吸 %1$s | 逗号 %2$d毫秒 | 句号 %3$d毫秒 | 段落 %4$d毫秒 | 页面 %5$s 逗号 @@ -170,12 +164,6 @@ 括号 %1$s | 对话 %2$s 括号 对话节奏 - 难度增强 - 难词的最大额外停顿。 - 复杂词增强 - 为超过复杂度阈值的词提供额外停顿。 - 复杂词阈值 - 越低 = 更多词被视为复杂。 节奏 稳定性 %1$s | 从句 %2$s | 单词间隔 %3$s 稳定性 @@ -635,4 +623,10 @@ 在阅读行跨分句和句子显示前后的词。在新段落处重置。 主词保持固定,在空间允许时显示当前分句中前后的词。暂停可回顾分句。 仅显示主要阅读文本。 + 阅读辅助 + 为单词和短语增加时间;英语还会考虑词语的熟悉程度。 + 单词分段提示 + 移动高亮,分段显示部分较长的英语单词。额外时间可单独调整。 + 关闭 + 强 diff --git a/app/src/main/res/values/settings_search.xml b/app/src/main/res/values/settings_search.xml index 6f0bebcb..76fb6318 100644 --- a/app/src/main/res/values/settings_search.xml +++ b/app/src/main/res/values/settings_search.xml @@ -8,9 +8,6 @@ Font size of the text shown while reading. Choose colours and a reading theme, including light and dark mode. Word length at which the long-word minimum time applies. - Extra reading time for words with more syllables. - Extra reading time for less common words. - Adjust how strongly word difficulty affects reading time. Adjust pacing for text inside parentheses. Adjust reading speed for dialogue. Move the RSVP text higher or lower on the screen. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5622cc27..7274d4cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -213,9 +213,8 @@ Extra time for long or technical words. Sentence end pause Breathing room at sentence boundaries. - Adaptive pacing - Adds extra hold on difficult words and clauses. - Difficulty +%1$dms | Complex +%2$dms | Threshold %3$s + Automatic cadence + Shape clause transitions and sentence endings. Reading support works independently. Comprehension Peripheral context, natural phrase units and easier rereading. Context assist @@ -234,17 +233,12 @@ Unlock extreme speeds Allows down to %1$dms (can become unreadable). Allows very high speeds (can quickly become unreadable). - Readability floors + Minimum display times Threshold %1$d chars | Split +%2$dms Long-word threshold Split-word pause Extra time between long-word chunks. - Difficulty model - Syll +%1$dms | Rarity +%2$dms | Strength %3$s - Syllable boost - Rarity boost - Complexity strength Punctuation pauses Breathing %1$s | Comma %2$dms | Period %3$dms | Paragraph %4$dms | Page %5$s Punctuation breathing @@ -277,12 +271,6 @@ Reads bracketed or dash-bounded asides as faster, quieter side remarks. Aside pace Lower = faster parenthetical aside words and punctuation. - Difficulty boost - Max extra hold for difficult words. - Complex word boost - Extra hold for words above the complexity threshold. - Complex word threshold - Lower = more words considered complex. Rhythm Stability %1$s | Clause %2$s | Separation %3$s Stability @@ -659,4 +647,10 @@ Shows preceding and upcoming words across clause and sentence boundaries on the reading line. Resets at a new paragraph. Shows surrounding words from the current clause where space allows, keeping the main word fixed. Pause to review the clause. Shows only the main reading text. + Reading support + Extra time for words and phrases; English also uses word familiarity. + Word-part guidance + Show selected long English words with a moving highlight. Extra time is controlled separately. + Off + Strong diff --git a/app/src/main/resources/reading/english-frequency.tsv.gz b/app/src/main/resources/reading/english-frequency.tsv.gz new file mode 100644 index 00000000..3a47e120 Binary files /dev/null and b/app/src/main/resources/reading/english-frequency.tsv.gz differ diff --git a/app/src/test/java/com/kairo/reader/core/model/RsvpProfileDefaultsTest.kt b/app/src/test/java/com/kairo/reader/core/model/RsvpProfileDefaultsTest.kt index ab88080d..f4756a74 100644 --- a/app/src/test/java/com/kairo/reader/core/model/RsvpProfileDefaultsTest.kt +++ b/app/src/test/java/com/kairo/reader/core/model/RsvpProfileDefaultsTest.kt @@ -173,7 +173,7 @@ class RsvpProfileDefaultsTest { assertFalse(study.useFocalStress) assertTrue(study.longWordMinMs > balanced.longWordMinMs) assertTrue(study.maxChunkLength >= 32) - assertTrue(study.adaptiveDifficultyMaxHoldMs > balanced.adaptiveDifficultyMaxHoldMs) + assertTrue(study.difficultWordSupport > balanced.difficultWordSupport) assertEquals(7, RsvpProfile.entries.map { it.defaultConfig().profileCadenceIdentity() }.toSet().size) } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpChunkingTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpChunkingTest.kt index 4948da5d..fd26149d 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpChunkingTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpChunkingTest.kt @@ -22,6 +22,7 @@ class ComprehensionRsvpChunkingTest : ComprehensionRsvpTestBase() { fun hyphenatedWordsAddMicroPauseBetweenParts() { val config = stableConfig.copy( + difficultWordSupport = 0.0, rarityExtraMaxMs = 0L, syllableExtraMs = 0L, complexityStrength = 0.0, 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 cb9fdc1a..40bef14d 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 @@ -442,7 +442,7 @@ class ComprehensionRsvpEngineTest { } @Test - fun adaptiveHoldAddsTimeForDifficultWords() { + fun readingSupportAddsTimeForDifficultWords() { val baseConfig = RsvpConfig( tempoMsPerWord = 115L, @@ -476,7 +476,7 @@ class ComprehensionRsvpEngineTest { engine.generateFrames( tokens = tokens, startIndex = 0, - config = baseConfig.copy(useAdaptiveTiming = false), + config = baseConfig.copy(difficultWordSupport = 0.0), ) assertTrue(adaptive[0].durationMs > baseline[0].durationMs) diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpPunctuationTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpPunctuationTest.kt index e26bb355..b22823d2 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpPunctuationTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpPunctuationTest.kt @@ -88,7 +88,7 @@ class ComprehensionRsvpPunctuationTest : ComprehensionRsvpTestBase() { ) assertTrue(abbrevEnd.isNotEmpty() && normalEnd.isNotEmpty()) - val diff = abs(abbrevEnd[0].durationMs - normalEnd[0].durationMs) + val diff = abs(requireNotNull(abbrevEnd[0].punctuationHoldMs) - requireNotNull(normalEnd[0].punctuationHoldMs)) assertTrue("Expected abbreviation at sentence end to pause like a sentence end", diff <= 5L) } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt index 55f5d884..6c67cf32 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt @@ -1,8 +1,10 @@ package com.kairo.reader.core.rsvp +import com.kairo.reader.core.model.Chapter 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.tokenization.TokenizerRegistry import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -14,7 +16,11 @@ class RsvpGenerationOptionsTest { "en-GB" to RsvpLanguagePolicy.ENGLISH, "eng" to RsvpLanguagePolicy.ENGLISH, "fr" to RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, + "de" to RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, + "es" to RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, + "pt-BR" to RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, "ja" to RsvpLanguagePolicy.CJK, + "zh-CN" to RsvpLanguagePolicy.CJK, "ar" to RsvpLanguagePolicy.RTL, null to RsvpLanguagePolicy.UNKNOWN, "und" to RsvpLanguagePolicy.UNKNOWN, @@ -24,6 +30,27 @@ class RsvpGenerationOptionsTest { } } + @Test + fun translatedLanguageBookPassagesKeepTheirTokensThroughPlayback() { + val passages = mapOf( + "de" to "Das Haus steht neben dem alten Fluss.", + "es" to "La casa está cerca del río antiguo.", + "fr" to "La maison est près de la rivière.", + "pt-BR" to "A casa fica perto do rio antigo.", + "ja" to "私は本を読んでいます。", + "zh-CN" to "我正在阅读这本书。", + ) + val engine = ComprehensionRsvpEngine() + val config = RsvpConfig(enablePhraseChunking = true, maxWordsPerUnit = 2, maxChunkLength = 32) + passages.forEach { (tag, passage) -> + val chapter = Chapter(0, tag, "", passage) + val tokens = TokenizerRegistry.resolve(tag).tokenize(chapter) + val frames = engine.generateFrames(tokens, 0, config, RsvpGenerationOptions.fromLanguageTag(tag)) + assertEquals(tag, tokens.map(Token::text), frames.flatMap { it.tokens }.map(Token::text)) + assertTrue(tag, frames.all { it.durationMs > 0L }) + } + } + @Test fun everyLanguageAndPersistedWidthUsesBoundedScoredUnitsWithoutLosingText() { val tokens = listOf("a", "b", "c", "d", "e", "f", "g").map { Token(it, TokenType.WORD) } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPhaseTwoSegmentationTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPhaseTwoSegmentationTest.kt index 7b9fdf91..48475e15 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPhaseTwoSegmentationTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPhaseTwoSegmentationTest.kt @@ -37,6 +37,9 @@ class RsvpPhaseTwoSegmentationTest { @Test fun namedEntitiesCreateObservableGroupingWithoutOverridingHardLimits() { + // Isolate phrase grouping: automatic word parts legitimately take priority + // over keeping a long member such as Organization inside a named-entity phrase. + val config = config.copy(maxChunkLength = 0) val entity = listOf(word("New"), word("York"), word("City"), word("expanded")) val scored = engine.generateFrames(entity, 0, config, englishOptions) diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPresetCadenceTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPresetCadenceTest.kt index b19b18ad..a9b7a785 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPresetCadenceTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPresetCadenceTest.kt @@ -27,14 +27,25 @@ class RsvpPresetCadenceTest { ) @Test - fun allProfilesKeepSourceWordsAndShowWholeOrdinaryTermsAtMultipleSpeeds() { + fun allProfilesKeepSourceWordsAndSupportMultisyllabicProseAtMultipleSpeeds() { for (tempo in listOf(80L, 150L, 250L)) { RsvpProfile.entries.forEach { profile -> val frames = engine.generateFrames(tokens, 0, profile.defaultConfig().copy(tempoMsPerWord = tempo), options) assertEquals( tokens.filter { it.type == TokenType.WORD }.map { it.text }, - frames.flatMap { it.tokens }.filter { it.type == TokenType.WORD }.map { it.text }, + frames.flatMap { it.tokens } + .filter { it.type == TokenType.WORD && (!it.isSubwordChunk || it.highlightStart == 0) } + .map { it.text }, ) + val displayed = frames.flatMap { it.tokens } + assertTrue(displayed.filter { it.text == "distinguish" }.all { !it.isSubwordChunk }) + assertTrue(displayed.filter { it.text == "measurements" }.all { !it.isSubwordChunk }) + for (supported in listOf("uncertainty", "observations")) { + val parts = displayed.filter { it.text == supported } + assertTrue("$profile at $tempo: $supported should receive moving support", parts.size > 1) + assertTrue(parts.all { it.isSubwordChunk }) + assertEquals(supported, parts.joinToString("") { it.text.substring(it.highlightStart!!, it.highlightEndExclusive!!) }) + } assertTrue(frames.all { it.durationMs > 0 }) } } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt new file mode 100644 index 00000000..ab81b2cd --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt @@ -0,0 +1,153 @@ +package com.kairo.reader.core.rsvp + +import com.kairo.reader.core.model.BlinkMode +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.analysis.RsvpReadingDemand +import com.kairo.reader.core.rsvp.engine.BoundaryBefore +import com.kairo.reader.core.rsvp.engine.ContextSnapshot +import com.kairo.reader.core.rsvp.engine.RhythmState +import com.kairo.reader.core.rsvp.timing.RsvpUnitTimingInput +import com.kairo.reader.core.rsvp.timing.computeUnitTiming +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpProtectedTimingTest { + private val easy = Token("cat", TokenType.WORD) + private val config = RsvpConfig( + tempoMsPerWord = 150L, minWordMs = 40L, longWordMinMs = 120L, + useAdaptiveTiming = false, useProsodyPacing = false, useClausePausing = false, + useDialogueDetection = false, usePunctuationLandingHold = false, + startDelayMs = 0L, endDelayMs = 0L, rampUpFrames = 0, rampDownFrames = 0, + ) + + @Test + fun difficultWordKeepsItsAllowanceAfterEasyWordsEvenWithNearFrozenRhythm() { + val rhythm = rhythm() + val baseline = duration(listOf(easy), rhythm) + repeat(8) { duration(listOf(easy), rhythm) } + val hard = duration(listOf(easy), rhythm, demands = listOf(RsvpReadingDemand(2.0))) + val unsupported = duration(listOf(easy), rhythm(), config.copy(difficultWordSupport = 0.0), listOf(RsvpReadingDemand(2.0))) + assertTrue(hard.protectedWordMs > 80L) + assertTrue(hard.durationMs >= unsupported.durationMs + hard.protectedWordMs - 15L) + assertTrue(kotlin.math.abs(duration(listOf(easy), rhythm).durationMs - baseline.durationMs) <= 1L) + } + + @Test + fun evenSmallAllowancesBypassSmoothing() { + val rhythm = rhythm() + val baseline = duration(listOf(easy), rhythm) + repeat(8) { duration(listOf(easy), rhythm) } + val held = duration(listOf(easy), rhythm, demands = listOf(RsvpReadingDemand(0.03))) + assertTrue(held.protectedWordMs in 3L..4L) + assertTrue(held.durationMs >= baseline.durationMs + 3L) + } + + @Test + fun explicitWordPausesAreAddedAfterTheDisplayFloor() { + val settings = config.copy(minWordMs = 220L) + val baseline = duration(listOf(easy), rhythm(), settings) + val held = duration(listOf(easy.copy(pauseAfterMs = 50L)), rhythm(), settings) + assertTrue(held.durationMs >= baseline.durationMs + 40L) + } + + @Test + fun groupingDoesNotLoseReadingTimeOrSlowTheNextSingleWord() { + val rhythm = rhythm() + for (count in listOf(1, 2, 3, 1, 3, 2, 1)) { + val independent = duration(List(count) { easy }, rhythm()) + assertEquals(independent, duration(List(count) { easy }, rhythm)) + } + } + + @Test + fun supportScalesAllowanceAndLeavesFloorsAlone() { + val demand = listOf(RsvpReadingDemand(1.0)) + val off = duration(listOf(easy), rhythm(), config.copy(difficultWordSupport = 0.0), demand) + val normal = duration(listOf(easy), rhythm(), config, demand) + val strong = duration(listOf(easy), rhythm(), config.copy(difficultWordSupport = 2.0), demand) + assertEquals(0L, off.protectedWordMs) + assertTrue(normal.protectedWordMs > 0L) + assertTrue(kotlin.math.abs(strong.protectedWordMs - 2 * normal.protectedWordMs) <= 1L) + for (support in listOf(0.0, 1.0, 2.0)) { + assertEquals(220L, duration(listOf(easy), rhythm(), config.copy(minWordMs = 220L, difficultWordSupport = support)).durationMs) + } + } + + @Test + fun easyCompanionsAndFinalPositionCannotDiluteAWordAllowance() { + val hard = RsvpReadingDemand(2.0) + val solo = duration(listOf(easy), rhythm(), demands = listOf(hard)) + val grouped = duration(listOf(easy, easy), rhythm(), demands = listOf(RsvpReadingDemand(0.0), hard)) + assertEquals(solo.protectedWordMs, grouped.protectedWordMs) + val engine = ComprehensionRsvpEngine() + val tokens = listOf(easy, Token("quizzacious", TokenType.WORD)) + val frames = engine.generateFrames(tokens, 0, config.copy(maxChunkLength = 30), RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)) + assertTrue(frames.last().protectedWordMs > 0) + } + + @Test + fun displayedAllowanceSurvivesWordSeparation() { + val tokens = listOf(easy, easy, Token("quizzacious", TokenType.WORD), easy) + val frames = ComprehensionRsvpEngine().generateFrames( + tokens, + 0, + config.copy(blinkMode = BlinkMode.SUBTLE, maxChunkLength = 0), + RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH), + ) + val hardFrame = frames.first { !it.isWordSeparation && it.originalTokenIndex == 2 } + assertTrue(hardFrame.protectedWordMs > 80L) + assertTrue(hardFrame.durationMs > hardFrame.protectedWordMs) + assertTrue(frames.none { it.isWordSeparation && it.protectedWordMs != 0L }) + } + + @Test + fun readabilityFloorCannotConsumeTheDifficultWordAllowance() { + for (tempo in listOf(50L, 150L, 300L)) { + for (count in listOf(1, 2, 3)) { + val settings = config.copy(tempoMsPerWord = tempo, minWordMs = 400L) + val words = List(count) { easy } + val demands = List(count) { RsvpReadingDemand(1.5) } + val off = duration(words, rhythm(), settings.copy(difficultWordSupport = 0.0), demands) + val on = duration(words, rhythm(), settings, demands) + assertTrue("tempo=$tempo count=$count", on.durationMs >= off.durationMs + on.protectedWordMs - 1L) + } + } + } + + @Test + fun longWordSupportRemainsVisibleAboveItsFloorWithSeparationEnabled() { + val settings = config.copy( + longWordMinMs = 400L, + maxChunkLength = 32, + enablePhraseChunking = false, + blinkMode = BlinkMode.SUBTLE, + ) + val tokens = listOf(easy, Token("neuroplasticity", TokenType.WORD), easy) + val frames = ComprehensionRsvpEngine().generateFrames(tokens, 0, settings, RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)) + val hard = frames.filter { !it.isWordSeparation && it.originalTokenIndex == 1 } + assertTrue(hard.size > 1) + assertTrue(hard.all { it.protectedWordMs > 0 }) + assertTrue(hard.all { it.durationMs >= settings.longWordMinMs + it.protectedWordMs }) + val firstPart = frames.indexOf(hard.first()) + val lastPart = frames.indexOf(hard.last()) + assertTrue(frames.subList(firstPart, lastPart + 1).none { it.isWordSeparation }) + } + + private fun rhythm() = RhythmState(smoothingAlpha = 0.01, maxSpeedupFactor = 1.01, maxSlowdownFactor = 1.01) + + private fun duration( + words: List, + rhythm: RhythmState, + settings: RsvpConfig = config, + demands: List = emptyList(), + ) = computeUnitTiming( + RsvpUnitTimingInput( + frameTokens = words, config = settings, contextBefore = ContextSnapshot(0, false), rhythm = rhythm, + prevToken = easy, prevWord = easy, nextToken = easy, nextWord = easy, boundaryBefore = BoundaryBefore.NONE, + readingDemands = demands, + ) + ) +} diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt new file mode 100644 index 00000000..45ebb055 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt @@ -0,0 +1,173 @@ +package com.kairo.reader.core.rsvp + +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.analysis.EnglishReadingFrequency +import com.kairo.reader.core.rsvp.analysis.ReadingDemandAnalyzer +import com.kairo.reader.core.rsvp.analysis.RsvpReadingDemand +import com.kairo.reader.core.rsvp.engine.ExpandedToken +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpReadingDemandTest { + private val english = RsvpLanguagePolicy.ENGLISH + private fun word(text: String) = Token(text, TokenType.WORD) + private fun demand(text: String, seen: Int = 0) = ReadingDemandAnalyzer.analyze(word(text), english, seen) + + @Test + fun bundledFrequenciesDistinguishActualFamiliarityFromLetterRarity() { + assertNotNull(EnglishReadingFrequency.zipf("extraordinary")) + assertTrue(EnglishReadingFrequency.zipf("people")!! > EnglishReadingFrequency.zipf("quartz")!!) + assertTrue(demand("people").recognition < demand("xqtrpz").recognition) + assertTrue(demand("the").allowanceMs(150.0) == 0.0) + } + + @Test + fun recognitionUsesTextRatherThanLegacyTokenEstimates() { + val token = word("extraordinary") + assertEquals( + ReadingDemandAnalyzer.analyze(token, english), + ReadingDemandAnalyzer.analyze( + token.copy(syllableCount = 99, frequencyScore = 0.0, complexityMultiplier = 99.0), + english, + ) + ) + } + + @Test + fun allowanceIsSmoothMonotoneAndBoundedAtEveryTempo() { + for (tempo in listOf(20.0, 150.0, 500.0, 1000.0)) { + val samples = (0..100).map { RsvpReadingDemand(it / 10.0).allowanceMs(tempo) } + assertEquals(0.0, samples.first(), 0.0) + assertTrue(samples.zipWithNext().all { (a, b) -> b >= a }) + assertTrue(samples.last() <= 180.0) + assertTrue(samples[2] - samples[1] < samples[1] - samples[0]) + } + } + + @Test + fun repetitionOnlyReducesNoveltyAndNeverRecognition() { + val first = demand("quizzacious") + val repeated = demand("quizzacious", 8) + assertEquals(first.recognition, repeated.recognition, 0.0) + assertTrue(first.novelty > repeated.novelty) + assertTrue(first.allowanceMs(150.0) > repeated.allowanceMs(150.0)) + assertTrue(repeated.allowanceMs(150.0) >= first.copy(novelty = 0.0).allowanceMs(150.0)) + assertTrue(first.allowanceMs(150.0) - repeated.allowanceMs(150.0) < 30.0) + } + + @Test + fun sourcePlanIsStableAcrossSeekingSentenceBoundariesAndParagraphResets() { + val tokens = listOf( + word("quizzacious"), + Token(".", TokenType.PUNCTUATION), + word("quizzacious"), + Token("\n", TokenType.PARAGRAPH_BREAK), + word("quizzacious") + ) + val complete = ReadingDemandAnalyzer.plan(tokens, 0, english) + val resumed = ReadingDemandAnalyzer.plan(tokens, 2, english) + assertEquals(complete[2], resumed[2]) + assertEquals(complete[0], complete[4]) + assertTrue(complete.getValue(0).novelty > complete.getValue(2).novelty) + assertEquals(complete, ReadingDemandAnalyzer.plan(tokens, 0, english)) + } + + @Test + fun unknownLanguagesDoNotInheritEnglishSpellingOrFrequencyPenalties() { + for (policy in RsvpLanguagePolicy.entries.filter { it != english }) { + assertEquals(ReadingDemandAnalyzer.analyze(word("people"), policy), ReadingDemandAnalyzer.analyze(word("xqtrpz"), policy)) + assertEquals(0.0, ReadingDemandAnalyzer.analyze(word("école"), policy).novelty, 0.0) + } + assertTrue(ReadingDemandAnalyzer.analyze(word("123456"), RsvpLanguagePolicy.UNKNOWN).recognition > 0.0) + } + + @Test + fun normalizationIsIndependentOfDeviceLocale() { + val previous = Locale.getDefault() + try { + Locale.setDefault(Locale.forLanguageTag("tr")) + assertEquals("isn't", ReadingDemandAnalyzer.normalize("ISN’T")) + assertEquals(ReadingDemandAnalyzer.normalize("école"), ReadingDemandAnalyzer.normalize("e\u0301cole")) + } finally { + Locale.setDefault(previous) + } + } + + @Test + fun splitWordsShareOneSourceAllowanceWithoutManufacturingNovelty() { + val token = word("quizzacious") + val pieces = listOf( + ExpandedToken(token.copy(isSubwordChunk = true, highlightStart = 0, highlightEndExclusive = 4), 0, 0, 0, 11), + ExpandedToken(token.copy(isSubwordChunk = true, highlightStart = 4, highlightEndExclusive = 11), 0, 1, 0, 11), + ) + val attached = ReadingDemandAnalyzer.attach(pieces, listOf(token), 0, english) + assertEquals(demand(token.text).allowanceMs(150.0), attached.sumOf { it.readingDemand!!.allowanceMs(150.0) }, 0.001) + assertEquals(attached[0].readingDemand!!.novelty, attached[1].readingDemand!!.novelty, 0.0) + } + + @Test + fun difficultWordsUseProgressivePartsWithoutAWholeWordFlash() { + val tokens = listOf(word("the"), word("neuroplasticity"), word("the")) + val engine = ComprehensionRsvpEngine() + val config = RsvpConfig( + maxChunkLength = 32, + enablePhraseChunking = false, + startDelayMs = 0, + endDelayMs = 0, + rampUpFrames = 0, + rampDownFrames = 0, + ) + fun frames(settings: RsvpConfig) = engine.generateFrames(tokens, 0, settings, RsvpGenerationOptions(english)) + val supported = frames(config) + assertEquals(null, supported.first().tokens.first().highlightStart) + val noExtraTime = frames(config.copy(difficultWordSupport = 0.0)) + assertEquals(supported.map { it.tokens }, noExtraTime.map { it.tokens }) + assertTrue(supported.sumOf { it.protectedWordMs } > noExtraTime.sumOf { it.protectedWordMs }) + assertTrue(frames(config.copy(showDifficultWordParts = false)).all { frame -> frame.tokens.all { it.highlightStart == null } }) + assertEquals(3, frames(config.copy(maxChunkLength = 0)).size) + val split = supported.flatMap { it.tokens }.filter { it.isSubwordChunk } + assertEquals(listOf("neuro", "plast", "icity"), split.map { it.text.substring(it.highlightStart!!, it.highlightEndExclusive!!) }) + assertEquals(0, split.first().highlightStart) + assertEquals(tokens[1].text.length, split.last().highlightEndExclusive) + assertTrue(split.zipWithNext().all { (a, b) -> a.highlightEndExclusive == b.highlightStart }) + assertTrue(split.all { it.text == tokens[1].text }) + assertEquals(split, frames(config.copy(maxChunkLength = 6)).flatMap { it.tokens }.filter { it.isSubwordChunk }) + val resumed = engine.generateFrames(tokens, 1, config, RsvpGenerationOptions(english)) + assertEquals(split, resumed.flatMap { it.tokens }.filter { it.isSubwordChunk }) + val hardFrames = supported.filter { it.originalTokenIndex == 1 } + assertTrue(hardFrames.dropLast(1).all { it.nextOriginalTokenIndex == 1 }) + assertEquals(2, hardFrames.last().nextOriginalTokenIndex) + } + + @Test + fun frequencyBackoffOnlyUsesKnownFormsAndDoesNotReplaceSurfaceFrequency() { + val singular = EnglishReadingFrequency.zipf("instructor")!! + assertTrue(EnglishReadingFrequency.recognitionZipf("instructors")!! > EnglishReadingFrequency.zipf("instructors")!!) + assertTrue(EnglishReadingFrequency.recognitionZipf("instructors")!! < singular) + assertEquals(EnglishReadingFrequency.zipf("representatives"), EnglishReadingFrequency.recognitionZipf("representatives")) + assertEquals(null, EnglishReadingFrequency.recognitionZipf("quizzacious")) + } + + @Test + fun obsoleteTuningCannotStackAnotherAllowanceAndSupportWorksWithCadenceDisabled() { + val config = RsvpConfig(startDelayMs = 0, endDelayMs = 0, rampUpFrames = 0, rampDownFrames = 0, useAdaptiveTiming = false) + val tokens = listOf(word("quizzacious"), word("12345"), word("neuroplasticity")) + val engine = ComprehensionRsvpEngine() + fun frames(settings: RsvpConfig) = engine.generateFrames(tokens, 0, settings, RsvpGenerationOptions(english)) + val normal = frames(config) + val legacy = frames( + config.copy( + syllableExtraMs = 100, rarityExtraMaxMs = 100, complexityStrength = 1.0, + lengthStrength = 3.0, lengthExponent = 3.0, complexWordHoldMs = 400, adaptiveDifficultyMaxHoldMs = 400, + complexWordThreshold = 1.0, phraseBreathingRoomMs = 400 + ) + ) + assertEquals(normal, legacy) + assertTrue(normal.sumOf { it.protectedWordMs } > frames(config.copy(difficultWordSupport = 0.0)).sumOf { it.protectedWordMs }) + } +} diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpRhythmSmoothingTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpRhythmSmoothingTest.kt index 8fa25f36..8d189a2a 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpRhythmSmoothingTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpRhythmSmoothingTest.kt @@ -87,7 +87,7 @@ class RsvpRhythmSmoothingTest { tempoMsPerWord = 150L, enablePhraseChunking = false, maxWordsPerUnit = 2, - maxChunkLength = 40, + maxChunkLength = 0, // Keep this test about whole-word smoothing, independent of moving-part support. startDelayMs = 0L, endDelayMs = 0L, rampUpFrames = 0, diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpScoredSegmentationTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpScoredSegmentationTest.kt index 3e80cfeb..f6fcc991 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpScoredSegmentationTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpScoredSegmentationTest.kt @@ -52,13 +52,13 @@ class RsvpScoredSegmentationTest { listOf( word("in"), word("the"), - word("puzzle", frequency = 0.0, complexity = 1.8, syllables = 3), + word("xqtrpz"), ) val scored = engine.generateFrames(tokens, 0, stableConfig, scoredOptions) assertEquals(listOf("in", "the"), scored.first().words()) - assertEquals(listOf("puzzle"), scored[1].words()) + assertEquals(listOf("xqtrpz"), scored[1].words()) } @Test @@ -67,7 +67,7 @@ class RsvpScoredSegmentationTest { listOf( word("in"), word("the"), - word("puzzle", frequency = 0.0, complexity = 1.8, syllables = 3), + word("xqtrpz"), word("on"), word("the"), word("table"), @@ -76,7 +76,7 @@ class RsvpScoredSegmentationTest { val frames = engine.generateFrames(tokens, 0, stableConfig, scoredOptions) assertEquals( - listOf(listOf("in", "the"), listOf("puzzle"), listOf("on", "the", "table")), + listOf(listOf("in", "the"), listOf("xqtrpz"), listOf("on", "the", "table")), frames.map { frame -> frame.words() }, ) } @@ -116,12 +116,12 @@ class RsvpScoredSegmentationTest { @Test fun persistedWidthsAboveThreeRetainDifficultyAwareScoring() { - val tokens = listOf(word("in"), word("the"), word("puzzle", frequency = 0.0, complexity = 1.8, syllables = 3)) + val tokens = listOf(word("in"), word("the"), word("xqtrpz")) listOf(4, 6, Int.MAX_VALUE).forEach { width -> val config = stableConfig.copy(maxWordsPerUnit = width, maxCharsPerUnit = 40) val frames = engine.generateFrames(tokens, 0, config, scoredOptions) assertEquals(listOf("in", "the"), frames.first().words()) - assertEquals(listOf("puzzle"), frames[1].words()) + assertEquals(listOf("xqtrpz"), frames[1].words()) } } @@ -131,7 +131,7 @@ class RsvpScoredSegmentationTest { listOf( word("in"), word("the"), - word("puzzle", frequency = 0.0, complexity = 1.8, syllables = 3), + word("xqtrpz"), word("on"), word("the"), word("table"), diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt index fe66e927..e6fec448 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt @@ -55,8 +55,51 @@ class RsvpThoughtFlowTest : ComprehensionRsvpTestBase() { val cues = plan(dense) assertTrue(cues.getValue(11).integrationHoldMs > 0.0) assertEquals(1, cues.values.count { it.integrationHoldMs > 0.0 }) - assertTrue(cues.values.sumOf { it.integrationHoldMs } <= stableConfig.adaptiveDifficultyMaxHoldMs) - assertEquals(0.0, plan(dense, stableConfig.copy(useAdaptiveTiming = false)).values.sumOf { it.integrationHoldMs }, 0.0) + assertTrue(cues.values.sumOf { it.integrationHoldMs } <= 70.0 * stableConfig.difficultWordSupport) + assertEquals(0.0, plan(dense, stableConfig.copy(difficultWordSupport = 0.0)).values.sumOf { it.integrationHoldMs }, 0.0) + } + + @Test + fun densePhrasesReceiveSomeProcessingTimeBeforeTheirLandingWithinOneBudget() { + val tokens = List(8) { w("concept$it").copy(frequencyScore = 0.1, complexityMultiplier = 1.8) } + val config = stableConfig.copy(phraseBreathingRoomMs = 120L) + val cues = plan(tokens, config).values.toList() + assertTrue(cues.dropLast(1).all { it.processingHoldMs > 0.0 }) + assertTrue(cues.last().integrationHoldMs > cues.sumOf { it.processingHoldMs }) + assertEquals(70.0, cues.sumOf { it.processingHoldMs + it.integrationHoldMs }, 0.001) + val disabled = plan(tokens, config.copy(difficultWordSupport = 0.0)).values + assertTrue(disabled.all { it.processingHoldMs == 0.0 && it.integrationHoldMs == 0.0 }) + val adaptiveOff = plan(tokens, config.copy(useAdaptiveTiming = false)).values + assertEquals(cues, adaptiveOff.toList()) + } + + @Test + fun languagesWithoutFamiliarityDataUseLengthAndNumbersInsteadOfTreatingEveryWordAsDense() { + val routine = listOf("the", "cat", "is", "in", "house", "with", "a", "dog").map(::w) + val expected = 14.0 // Eight distinct words contribute one length-load unit. + for (policy in listOf( + RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, + RsvpLanguagePolicy.CJK, + RsvpLanguagePolicy.RTL, + RsvpLanguagePolicy.UNKNOWN, + )) { + val cues = plan(routine, language = policy).values + assertEquals(policy.name, expected, cues.sumOf { it.processingHoldMs + it.integrationHoldMs }, 0.001) + val numbered = plan(routine.dropLast(1) + w("2026"), language = policy).values + assertTrue(policy.name, numbered.sumOf { it.processingHoldMs + it.integrationHoldMs } > expected) + } + } + + @Test + fun splittingAWordDoesNotMultiplyItsPhraseProcessingAllowance() { + val tokens = List(8) { w("concept$it").copy(frequencyScore = 0.1, complexityMultiplier = 1.8) } + val original = tokens.mapIndexed { index, token -> ExpandedToken(token, index, index, 0, token.text.length) } + val split = original.flatMap { entry -> + listOf(entry.copy(token = entry.token.copy(isSubwordChunk = true)), entry.copy(token = entry.token.copy(isSubwordChunk = true))) + }.mapIndexed { index, entry -> entry.copy(expandedIndex = index) } + fun total(words: List) = RsvpThoughtPlan.analyze(words, stableConfig, RsvpLanguagePolicy.ENGLISH) + .values.sumOf { it.processingHoldMs + it.integrationHoldMs } + assertEquals(total(original), total(split), 0.001) } @Test diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpWordPartSupportTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpWordPartSupportTest.kt new file mode 100644 index 00000000..8075245f --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpWordPartSupportTest.kt @@ -0,0 +1,171 @@ +package com.kairo.reader.core.rsvp + +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.model.splitTokenForRsvp +import com.kairo.reader.core.rsvp.analysis.ReadingDemandAnalyzer +import com.kairo.reader.core.rsvp.analysis.RsvpWordPartSupport +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpWordPartSupportTest { + private val config = RsvpConfig(maxChunkLength = 32, subwordChunkPauseMs = 35L) + private val english = RsvpLanguagePolicy.ENGLISH + + @Test + fun familiarAndShortUncertainWordsStayWhole() { + val bases = listOf( + "instructor", "teacher", "student", "instruction", "whispered", "distinguish", + "comfortable", "everything", "conversation", "information", "international", + "professional", "opportunity", "university", "explanation", "measurement", "Mavik", "Zalorin", + ) + for (base in bases) { + for (form in listOf(base, base.uppercase(Locale.ROOT), "$base's")) { + assertEquals(form, 1, parts(form).size) + } + } + } + + @Test + fun uncommonFamiliesKeepTheSameDecisionAcrossTheOldTimingCutoff() { + for ((singular, plural) in listOf("slateboard" to "slateboards", "crucible" to "crucibles")) { + val before = ReadingDemandAnalyzer.analyze(word(singular), english) + val after = ReadingDemandAnalyzer.analyze(word(plural), english) + assertTrue(before.difficulty < 0.68 && after.difficulty > 0.68) + assertTrue(after.allowanceMs(150.0) > before.allowanceMs(150.0)) + assertTrue(after.allowanceMs(150.0) - before.allowanceMs(150.0) < 10.0) + assertTrue(parts(singular).size > 1) + assertFamily(singular, listOf(plural)) + } + } + + @Test + fun longStructuredFamiliesKeepTheirPartCountAndBoundariesAcrossInflections() { + val families = listOf( + "observation" to "observations", + "examination" to "examinations", + "crucible" to "crucibles", + "dormitory" to "dormitories", + "alabaster" to "alabasters", + "representative" to "representatives", + "responsibility" to "responsibilities", + "neuroplasticity" to "neuroplasticities", + "photosynthesis" to "photosyntheses", + "misrepresentation" to "misrepresentations", + "misinterpretation" to "misinterpretations", + "administration" to "administrations", + "electrocardiography" to "electrocardiographies", + "unconsciousness" to "unconsciousnesses", + "counterargument" to "counterarguments", + ) + for ((base, plural) in families) { + assertTrue(base, parts(base).size > 1) + assertFamily(base, listOf(plural, "$base's", "$base’s", "$plural'", base.uppercase(Locale.ROOT))) + } + } + + @Test + fun commonFamiliesAndVerbFormsDoNotBecomeDifficultBecauseTheirFormIsLessFrequent() { + for ((base, plural) in listOf( + "instructor" to "instructors", + "university" to "universities", + "explanation" to "explanations", + "measurement" to "measurements", + )) { + assertEquals(base, 1, parts(base).size) + assertFamily(base, listOf(plural, "$base's", "$plural'", plural.uppercase(Locale.ROOT))) + } + for (form in listOf("whispered", "whispering", "deepening", "retrieving", "painfully", "nervously", "cleanliness", "unfairness")) { + assertEquals(form, 1, parts(form).size) + } + } + + @Test + fun uncommonOrUnfamiliarComplexWordsReceiveSupportWithoutNeedingFourteenLetters() { + for (text in listOf("crucible", "crystalline", "dormitory", "equatorial", "pantomime", "alabaster", "slateboard", "quizzacious")) { + assertTrue(text, parts(text).size > 1) + } + } + + @Test + fun generatedUnknownFamiliesStayStableAcrossLengthAndPartCountBoundaries() { + // Exercise missing dictionary entries and the old 6-character and score cliffs over + // many family shapes, rather than maintaining an exception list for reported words. + for (length in 7..24) { + for ((ending, pluralEnding) in listOf("or" to "ors", "ity" to "ities", "ation" to "ations", "ness" to "nesses")) { + val prefix = "bada".repeat(length).take(length - ending.length) + val base = prefix + ending + val plural = prefix + pluralEnding + assertFamily(base, listOf(plural, "$base's", "$plural'", base.uppercase(Locale.ROOT))) + } + } + } + + @Test + fun displayPlanIgnoresLegacyDifficultyScoresTempoAndPositiveSupportStrength() { + for (text in listOf("slateboard", "crucibles", "representative", "neuroplasticity")) { + val original = word(text) + val changed = original.copy(syllableCount = 99, frequencyScore = 0.0, complexityMultiplier = 99.0) + for (tempo in listOf(50L, 150L, 500L)) { + for (support in listOf(0.0, 0.01, 0.25, 1.0, 2.0)) { + val settings = config.copy(tempoMsPerWord = tempo, difficultWordSupport = support) + assertEquals(ranges(parts(text)), ranges(RsvpWordPartSupport.split(changed, settings, english))) + } + } + } + } + + @Test + fun languageOptOutAndExplicitLimitsKeepTheirConfiguredBehavior() { + for (policy in RsvpLanguagePolicy.entries.filter { it != english }) { + for (text in listOf("neuroplasticity", "阅读理解阅读理解阅读理解", "المستشفيات")) { + val token = word(text) + assertEquals(splitTokenForRsvp(token, 32, 35L), RsvpWordPartSupport.split(token, config, policy)) + } + } + for (text in listOf("1234567890123456", "well-understood", "électromagnétique")) { + assertEquals(splitTokenForRsvp(word(text), 32, 35L), parts(text)) + } + for (settings in listOf(config.copy(showDifficultWordParts = false), config.copy(maxChunkLength = 0))) { + assertEquals(1, RsvpWordPartSupport.split(word("neuroplasticity"), settings, english).size) + } + assertTrue(parts("neuroplasticity").size > 1) + assertTrue(RsvpWordPartSupport.split(word("neuroplasticity"), config.copy(difficultWordSupport = 0.0), english).size > 1) + for (limit in listOf(4, 6, 7)) { + val token = word("electrocardiographies") + val split = RsvpWordPartSupport.split(token, config.copy(maxChunkLength = limit), english) + assertTrue(split.all { it.highlightEndExclusive!! - it.highlightStart!! <= limit }) + assertEquals(token.text, split.joinToString("") { it.text.substring(it.highlightStart!!, it.highlightEndExclusive!!) }) + } + } + + @Test + fun automaticPartsPreserveSourceMetadataAndFinalPause() { + val token = word("representatives").copy(pauseAfterMs = 90L, isClauseBoundary = true, isDialogue = true, linkChapterIndex = 3) + val split = RsvpWordPartSupport.split(token, config, english) + assertTrue(split.all { it.text == token.text && it.linkChapterIndex == 3 && it.isDialogue }) + assertTrue(split.dropLast(1).all { !it.isClauseBoundary && it.pauseAfterMs == 35L }) + assertTrue(split.last().isClauseBoundary) + assertEquals(90L, split.last().pauseAfterMs) + } + + private fun assertFamily(base: String, forms: List) { + val expected = parts(base) + for (form in forms) { + val actual = parts(form) + assertEquals("$base / $form", expected.size, actual.size) + if (expected.size > 1) { + assertEquals(form, ranges(expected).dropLast(1), ranges(actual).dropLast(1)) + assertEquals(form, form.length, actual.last().highlightEndExclusive) + assertEquals(form, actual.joinToString("") { it.text.substring(it.highlightStart!!, it.highlightEndExclusive!!) }) + } + } + } + + private fun word(text: String) = Token(text, TokenType.WORD) + private fun parts(text: String) = RsvpWordPartSupport.split(word(text), config, english) + private fun ranges(tokens: List) = tokens.map { it.highlightStart to it.highlightEndExclusive } +} diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomizationTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomizationTest.kt index 531644d6..e3ce46c5 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomizationTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomizationTest.kt @@ -181,7 +181,7 @@ class RsvpAtomizationTest { @Test fun disabledParentheticalAnalysisCannotInfluenceScoredGrouping() { - val tokens = listOf(punctuation("("), word("zog"), word("mif"), punctuation(")")) + val tokens = listOf(punctuation("("), word("red"), word("hat"), punctuation(")")) val baseConfig = RsvpConfig( enablePhraseChunking = true, @@ -205,7 +205,8 @@ class RsvpAtomizationTest { assertEquals(2, enabled.selectedWordCount) assertTrue(enabled.components.any { it.reason == RsvpSegmentationReason.PARENTHETICAL_COHESION }) - assertEquals(1, disabled.selectedWordCount) + // Familiar words can still group; disabling the aside must remove its affinity. + assertTrue(enabled.pathScore > disabled.pathScore) assertTrue(disabled.components.none { it.reason == RsvpSegmentationReason.PARENTHETICAL_COHESION }) } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpBoundaryIntegrationTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpBoundaryIntegrationTest.kt index b3ab3f63..937a5a3a 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpBoundaryIntegrationTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpBoundaryIntegrationTest.kt @@ -6,7 +6,6 @@ import com.kairo.reader.core.model.TokenType import com.kairo.reader.core.rsvp.analysis.RsvpThoughtCue import com.kairo.reader.core.rsvp.engine.BoundaryBefore import com.kairo.reader.core.rsvp.engine.ContextSnapshot -import com.kairo.reader.core.rsvp.engine.FlowState import com.kairo.reader.core.rsvp.engine.RhythmState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -32,7 +31,6 @@ class RsvpBoundaryIntegrationTest { config = config, contextBefore = ContextSnapshot(0, false), rhythm = RhythmState(1.0, 2.0, 2.0), - flow = FlowState(1.0, 1.0, 1.0, 0.0), prevToken = null, prevWord = null, nextToken = Token("Next", TokenType.WORD), diff --git a/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt b/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt index f0ec7270..14c20e95 100644 --- a/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt +++ b/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt @@ -67,9 +67,55 @@ class RsvpPreferenceCodecsTest { assertEquals(listOf(profile), profileCodec.parseCustomProfiles(json.toString())) } + @Test + fun legacyAllowancesRoundTripWhileReadingSupportKeepsItsStoredValue() { + val prefs = mutablePreferencesOf() + prefs[PrefKeys.adaptiveDifficultyMaxHoldMs] = 95L + val legacy = preferenceCodec.readRsvpConfig(prefs, RsvpConfig()) + assertEquals(95L, legacy.phraseBreathingRoomMs) + assertEquals(1.0, legacy.difficultWordSupport, 0.0) + assertTrue(legacy.showDifficultWordParts) + preferenceCodec.writeRsvpConfig(prefs, legacy.copy(phraseBreathingRoomMs = 0L, difficultWordSupport = 1.5)) + assertEquals(0L, preferenceCodec.readRsvpConfig(prefs, RsvpConfig()).phraseBreathingRoomMs) + val json = """[{"id":"user:old","name":"Old","config":{"adaptiveDifficultyMaxHoldMs":95}}]""" + val restored = profileCodec.parseCustomProfiles(json).single().config + assertEquals(95L, restored.phraseBreathingRoomMs) + assertEquals(1.0, restored.difficultWordSupport, 0.0) + assertTrue(restored.showDifficultWordParts) + } + + @Test + fun existingOffSupportKeepsAutomaticWordPartsOffUntilTheNewSwitchIsChosen() { + val prefs = mutablePreferencesOf() + prefs[PrefKeys.difficultWordSupport] = 0.0 + assertTrue(!preferenceCodec.readRsvpConfig(prefs, RsvpConfig()).showDifficultWordParts) + prefs[PrefKeys.showDifficultWordParts] = true + assertTrue(preferenceCodec.readRsvpConfig(prefs, RsvpConfig()).showDifficultWordParts) + + val oldJson = """[{"id":"user:off","name":"Off","config":{"difficultWordSupport":0}}]""" + assertTrue(!profileCodec.parseCustomProfiles(oldJson).single().config.showDifficultWordParts) + } + + @Test + fun comprehensionControlsClampMalformedStoredValues() { + val prefs = mutablePreferencesOf() + prefs[PrefKeys.difficultWordSupport] = Double.NaN + prefs[PrefKeys.phraseBreathingRoomMs] = -5L + val decoded = preferenceCodec.readRsvpConfig(prefs, RsvpConfig()) + assertEquals(1.0, decoded.difficultWordSupport, 0.0) + assertEquals(0L, decoded.phraseBreathingRoomMs) + val json = """[{"id":"user:bad","name":"Bad","config":{"difficultWordSupport":99,"phraseBreathingRoomMs":-1}}]""" + val restored = profileCodec.parseCustomProfiles(json).single().config + assertEquals(2.0, restored.difficultWordSupport, 0.0) + assertEquals(0L, restored.phraseBreathingRoomMs) + } + private fun distinctiveConfig(): RsvpConfig = RsvpConfig( tempoMsPerWord = 137L, + difficultWordSupport = 1.4, + showDifficultWordParts = false, + phraseBreathingRoomMs = 123L, minWordMs = 41L, longWordMinMs = 171L, longWordChars = 11, diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpAccentCueTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpAccentCueTest.kt new file mode 100644 index 00000000..6de3b69d --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpAccentCueTest.kt @@ -0,0 +1,26 @@ +package com.kairo.reader.ui.rsvp + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextDecoration +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RsvpAccentCueTest { + @Test + fun pivotOnlyChangesCharacterColorWhileWordPartKeepsUnderline() { + val text = buildOrpAnnotatedText( + fullText = "neuroplasticity", + pivotPosition = 2, + pivotColor = Color.Red, + highlightStart = 5, + highlightEndExclusive = 10, + highlightColor = Color.Blue, + ) + assertEquals(listOf(Color.Blue, Color.Red), text.spanStyles.map { it.item.color }) + assertEquals(listOf(TextDecoration.Underline, null), text.spanStyles.map { it.item.textDecoration }) + assertNull(text.spanStyles.last().item.fontWeight) + assertEquals(Color.Unspecified, text.spanStyles.last().item.background) + assertEquals(listOf(5 to 10, 2 to 3), text.spanStyles.map { it.start to it.end }) + } +} diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightTest.kt new file mode 100644 index 00000000..d45e0df2 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightTest.kt @@ -0,0 +1,62 @@ +package com.kairo.reader.ui.rsvp + +import androidx.compose.ui.graphics.Color +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpDifficultyHighlightTest { + @Test + fun everyDifficultWordInAPhraseRetainsItsHighlightAndPivot() { + val tokens = listOf(word("the"), highlighted("quartz"), highlighted("quizzacious")) + val content = buildOrpTextContent(tokens) + val plain = buildOrpTextContent(tokens.map { it.copy(highlightStart = null, highlightEndExclusive = null) }) + assertEquals(plain.pivotPosition, content.pivotPosition) + val annotated = buildOrpAnnotatedText( + content.fullText, + content.pivotPosition, + Color.Red, + content.highlightStart, + content.highlightEndExclusive, + Color.Blue, + additionalHighlights = content.additionalHighlights, + ) + assertEquals( + listOf("quartz", "quizzacious"), + annotated.spanStyles.filter { it.item.color == Color.Blue }.map { + annotated.text.substring(it.start, it.end) + } + ) + assertEquals(Color.Red, annotated.spanStyles.last().item.color) + } + + @Test + fun movingWordPartHighlightRemainsWhenThePivotHighlightIsHidden() { + val content = buildOrpTextContent( + listOf( + highlighted("neuroplasticity").copy( + isSubwordChunk = true, + highlightStart = 5, + highlightEndExclusive = 10, + ) + ) + ) + val annotated = buildOrpAnnotatedText( + content.fullText, + content.pivotPosition, + Color.Red, + content.highlightStart, + content.highlightEndExclusive, + Color.Blue, + pivotHighlightVisible = false, + ) + assertEquals("neuroplasticity", annotated.text) + assertEquals("plast", annotated.text.substring(annotated.spanStyles.single().start, annotated.spanStyles.single().end)) + assertTrue(annotated.spanStyles.all { it.item.color == Color.Blue }) + } + + private fun word(text: String) = Token(text, TokenType.WORD) + private fun highlighted(text: String) = word(text).copy(highlightStart = 0, highlightEndExclusive = text.length) +} diff --git a/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt b/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt new file mode 100644 index 00000000..a72ad90d --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt @@ -0,0 +1,65 @@ +package com.kairo.reader.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.ReaderTheme +import kotlin.math.abs +import kotlin.math.max +import kotlin.random.Random +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpAccentColorsTest { + @Test + fun everyBuiltInThemeHasLegibleDistinctRsvpAccents() { + ReaderTheme.entries.filter { it != ReaderTheme.CUSTOM }.forEach { theme -> + assertAccents(theme.materialColorScheme()) + } + } + + @Test + fun warmPageUsesACoolPivotMarkerEvenWithAWarmCustomTertiary() { + val scheme = CustomTheme( + background = Color(0xFFF2E6D2).toArgb(), + tertiary = Color(0xFF7A4B1F).toArgb(), + ).materialColorScheme() + val pivot = scheme.rsvpAccentColors().pivot + assertTrue("Pivot should stand apart from the warm page: $pivot", pivot.blue > pivot.red) + assertAccents(scheme) + } + + @Test + fun customThemesStayLegibleWhenAccentsMatchOrHavePoorContrast() { + val random = Random(PALETTE_RANDOM_SEED) + repeat(CUSTOM_PALETTE_SAMPLES) { + val accent = random.nextInt() + assertAccents( + CustomTheme( + background = random.nextInt(), + primary = accent, + tertiary = if (it % MATCHED_ACCENT_INTERVAL == 0) accent else random.nextInt(), + ).materialColorScheme() + ) + } + } + + private fun assertAccents(scheme: androidx.compose.material3.ColorScheme) { + val (pivot, wordPart) = scheme.rsvpAccentColors() + val background = Color(scheme.background.toArgb()) + listOf(pivot, wordPart).forEach { color -> + assertTrue(colorContrast(Color(color.toArgb()), background) >= WCAG_AA_TEXT_CONTRAST - CONTRAST_TOLERANCE) + } + val distance = max(abs(pivot.red - wordPart.red), max(abs(pivot.green - wordPart.green), abs(pivot.blue - wordPart.blue))) + assertTrue("RSVP accent colors are too similar on $background: $pivot and $wordPart", distance >= MIN_VISIBLE_CHANNEL_DELTA) + } + + private companion object { + const val PALETTE_RANDOM_SEED = 417 + const val CUSTOM_PALETTE_SAMPLES = 100 + const val MATCHED_ACCENT_INTERVAL = 2 + const val WCAG_AA_TEXT_CONTRAST = 4.5f + const val CONTRAST_TOLERANCE = 0.0001f + const val MIN_VISIBLE_CHANNEL_DELTA = 0.05f + } +} diff --git a/docs/rsvp-comprehension-timing.md b/docs/rsvp-comprehension-timing.md new file mode 100644 index 00000000..001c51e2 --- /dev/null +++ b/docs/rsvp-comprehension-timing.md @@ -0,0 +1,153 @@ +# RSVP reading demand and comprehension timing + +Kairo uses a deterministic reading-demand calculation. There is no trained timing +model, reader history, network inference, or runtime Python dependency. + +## One word allowance + +`ReadingDemandAnalyzer` computes recognition demand once from a smooth logarithmic +length curve and word rarity. For English, a structural decoding floor also accounts +for longer spelling and estimated vowel groups. It is a minimum recognition score, +not a second timing allowance; familiarity cannot erase this decoding work. English rarity uses a bundled table of 29,665 words +and contractions, derived from wordfreq 3.1.1. Familiar base forms can support simple +plurals and possessives: recognition uses the higher of the surface frequency and a +known base frequency minus 0.15 Zipf units. Unknown bases contribute no evidence. Unlisted English words use a moderate +spelling fallback, with a bounded consonant-cluster adjustment. Missing from the +table does not imply maximum difficulty. Digits receive a structural allowance. +Other language policies use script-appropriate length scaling and digit structure; +they do not inherit English frequency or pronunciation assumptions. UNKNOWN stays +conservative until the book has an explicit language policy. + +Repeated English terms receive less of a small novelty allowance. Recognition time +never decreases because of repetition. Counts use normalized source words, reset at +paragraph/page boundaries, saturate at eight prior occurrences and retain at most +2,048 distinct recent words. Seeking seeds these counters from the containing +paragraph, so a reload does not invent a first occurrence. This is local text state, +not evidence that the reader understands a term. + +The extra word time is: + +``` +cap = clamp(0.8 × base tempo, 80 ms, 180 ms) +allowance = support × cap × (1 − exp(−recognition − novelty)) +``` + +Both recognition and novelty share this cap. At Strong (200%), the maximum is twice +the cap. Constants are engineering defaults, not coefficients fitted to eye tracking. +Split-word frames divide one source allowance by their displayed letter/digit counts; +they cannot multiply novelty or manufacture another whole-word allowance. + +The old syllable/rarity/complexity stack, complex-word threshold hold, difficulty-flow +EMA and given/new beat compression no longer affect playback. Phrase scoring obtains +its difficulty evidence from the same recognition calculation, independently of +tempo and support strength. Automatic word-part expansion is separate. + +## A separate word-part presentation policy + +`RsvpWordPartSupport` decides whether moving highlights can help with a long spelling. +It combines family-level vocabulary familiarity with spelling shape, independently of +the timing score, token difficulty metadata, tempo, or repetition. There is no timing-score cutoff that converts a small timing +change into extra display frames. Words below the spelling thresholds still receive +their gradual reading allowance while remaining whole. + +Automatic support is English-only. Selection uses the regular noun base: + +- Very long spellings (14 letters, four estimated vowel groups) receive parts regardless of familiarity. +- Known uncommon vocabulary (Zipf at most 3.5) needs eight letters and three vowel groups. +- Moderately familiar vocabulary (Zipf at most 4.3) needs ten letters and four vowel groups. +- Missing dictionary entries need stronger spelling evidence: ten letters and three groups, + or eight letters and four groups. Absence alone never triggers highlighting. +- Familiar words below the very-long-word threshold remain whole. Attested stems with regular + `-ed`, `-ing`, `-ly`, `-ness`, `-ment`, `-less`, and `-en` endings provide familiarity evidence + with a small 0.15 Zipf cost per step. At most two steps are considered, including silent-e, + doubled-consonant and y/i spelling changes; unattested stems supply no evidence. + +These are legibility and general familiarity heuristics, not predictions of the reader's vocabulary. +Regular plurals and possessives share +that base even when it is missing from the frequency table. Ambiguous `-es` / `-is` +inflections also share a base when it is attested in the dictionary. Selection uses that +base's familiarity, so rarer inflections cannot flip the decision. The base is used only to +plan the display; the original spelling and capitalisation stay on screen. + +Base words are divided into balanced parts of up to six characters. Inflection +endings stay on the final part, preserving the number of beats and earlier boundaries +across the family. These are character chunks, not exact spoken syllables. Existing +explicit chunk limits remain authoritative, including tighter limits that can require +more parts. Numbers, hyphenated words, other scripts and non-English policies retain +their configured splitting behavior. A zero limit disables splitting. + +Each part keeps its reading beat and display floor, while the source word's extra +recognition allowance is shared across the parts. No blank separation frame is +inserted between parts of the same source word. **Word-part guidance** in Advanced → +Readability controls this presentation separately from Reading support. Turning it +off restores configured length-only splitting; a zero chunk length disables splitting. + +## A separate phrase allowance + +For English, phrase processing uses distinct information-word count, numbers and +phrase length, not another spelling-complexity sum. Other language policies and +UNKNOWN use phrase length and numbers only; without language-specific familiarity +data, distinct words alone do not imply density. Phrases receive at most 70 ms at +100% support. The same support slider scales this allowance. Existing phrase distribution +is retained: 40% through a multiword phrase and 60% at its landing. A single source +word receives any allowance at its landing. Split words count once. + +Rhythm smoothing affects the beat only. Word allowances and phrase processing bypass +it, remain protected during live speed changes and cannot be consumed by word +separation. Word allowances are added after the minimum display floor, so a short +beat or a high readability floor cannot swallow the difficult-word support. Minimum display times and explicit split-word pauses remain authoritative. +Overlapping automatic landing cues use the strongest cue; explicit punctuation pauses +remain additive. + +## Controls and compatibility + +The everyday sliders are **Speed** and **Reading support** (Off–200%, labelled Strong +at the maximum). Reading support scales extra time without changing word-part frame +count. **Word-part guidance** is a separate Advanced switch, on by default; it is +English-only and does not change the extra-time strength. The separate phrase slider and six difficulty fine-tuning controls +are removed from both settings and search. **Automatic cadence** is in Advanced → +Rhythm and controls existing clause/sentence shaping independently of Reading support. +Punctuation preferences and readability floors remain separate. + +Reading support retains the `difficult_word_support` DataStore key and +`difficultWordSupport` profile JSON property. Existing values are preserved and clamped +to 0–2; older profiles without this field receive 100%. Word-part guidance persists as +`show_difficult_word_parts` / `showDifficultWordParts`. Older profiles default to on, +except when their stored Reading support was Off; that choice keeps guidance off until +the new switch is changed. +The retired numeric fields +still round-trip to avoid destructive changes to saved profiles, but playback ignores +them. This is a new timing policy, not an exact reproduction of old custom tuning. +Study selects 150% support and Sprint 50%; other existing presets retain 100%. + +## Lookup data and reproducibility + +The compressed lookup occupies 132,195 bytes and is loaded lazily once. It is shipped +as a Java resource, including in the Android APK. `scripts/export-reading-frequency.py` +reproduces it with wordfreq 3.1.1; it exports statistics and does not train weights. +The previous experimental `scripts/reading-model/train.py` is not used by this path. +See `app/src/main/assets/licenses/wordfreq-NOTICE.md` for attribution, upstream +sources and the CC BY-SA 4.0 license covering the derived frequency table. + +## Validation boundary + +Regression coverage checks monotonic bounded word time, real frequency lookup, +independence from old token scores and controls, limited novelty decay, language +fallbacks, locale-independent normalization, source-based seeking, split-word +conservation, final/grouped words, smoothing, floors, word separation and persistence. +Device tests exercise real exposure and the two-slider settings UI. These checks +establish implementation behavior; improved comfort or comprehension still needs +reading trials at matched total presentation time. + +### Device measurements (2026-09-13) + +All eight targeted checks passed on the connected CPH2493: three settings parity/ +control tests, two real-exposure tests, two sweep/reload tests, and one packaged +English-lookup/determinism test. The final settings wording was rechecked separately. +The mixed 5,000-word English fixture took 1,658 ms on its first full generation and +1,000/894 ms on two warm full generations. The separate 5,000-word repository fixture +took 1,333 ms initially and 21/21/22 ms on cached reloads. These fixtures measure +different operations and are smoke measurements, not a comparison with the old engine. + +Final `qualityGate` and instrumented APK assembly passed: 860 unit tests, no failures, +errors or skips; formatting and Detekt passed; Android Lint reported no errors or warnings. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09ea10d3..32926b42 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.4.0" +agp = "9.4.1" kotlin = "2.4.20" compileSdk = "37" targetSdk = "37" @@ -12,7 +12,7 @@ navigationCompose = "2.10.0" lifecycle = "2.11.0" room = "2.8.4" datastore = "1.2.1" -coil = "3.6.2" +coil = "3.6.3" jsoup = "1.23.2" pdfboxAndroid = "2.0.27.0" coreKtx = "1.19.0" diff --git a/scripts/export-reading-frequency.py b/scripts/export-reading-frequency.py new file mode 100644 index 00000000..c368a32b --- /dev/null +++ b/scripts/export-reading-frequency.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Export fixed lookup data, not fitted timing weights. Requires wordfreq==3.1.1. + +The derived table is CC BY-SA 4.0; see app/src/main/assets/licenses/wordfreq-NOTICE.md. +""" +import gzip +import importlib.metadata +import re +from pathlib import Path + +from wordfreq import top_n_list, zipf_frequency + +assert importlib.metadata.version("wordfreq") == "3.1.1" +root = Path(__file__).resolve().parents[1] +target = root / "app/src/main/resources/reading/english-frequency.tsv.gz" +rows = [] +for word in top_n_list("en", 30000): + if re.fullmatch(r"[a-z]+(?:'[a-z]+)?", word): + rows.append((word, round(zipf_frequency(word, "en") * 100))) +payload = "".join(f"{word}\t{score}\n" for word, score in sorted(rows)).encode() +target.parent.mkdir(parents=True, exist_ok=True) +target.write_bytes(gzip.compress(payload, mtime=0)) +print(f"{len(rows)} entries, {target.stat().st_size} bytes")