diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4ef4a9ad..ad851aa8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,6 +146,9 @@ android { lint { // AGP stays within the installed Android Studio support range; keep updates visible in lint. informational += "AndroidGradlePluginVersion" + // New library releases are maintenance advice, not a defect in the pinned build. + // Keep them visible without forcing unplanned runtime upgrades to pass CI. + informational += "GradleDependency" warningsAsErrors = true error += "AutoboxingStateCreation" abortOnError = true diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceFixture.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceFixture.kt index 642fe6c1..641b07da 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceFixture.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceFixture.kt @@ -1,5 +1,6 @@ package com.kairo.reader.ui.rsvp +import android.os.SystemClock import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -34,6 +35,7 @@ internal class RsvpDeviceFixture(tokens: List, config: RsvpConfig, startI ), ) val consumed = CopyOnWriteArrayList() + val consumedAtMs = CopyOnWriteArrayList() @Volatile var saved: RsvpResumePoint? = null @@ -64,7 +66,10 @@ internal class RsvpDeviceFixture(tokens: List, config: RsvpConfig, startI onPositionChanged = {}, onTempoChange = {}, onExit = { saved = it }, - onFrameConsumed = { consumed += it }, + onFrameConsumed = { + consumedAtMs += SystemClock.elapsedRealtime() + consumed += it + }, ), preferences = RsvpPreferenceCallbacks( onExtremeSpeedUnlockedChange = {}, diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceSmokeTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceSmokeTest.kt index 4e6c43b8..6882ecdc 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceSmokeTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDeviceSmokeTest.kt @@ -20,9 +20,12 @@ import com.kairo.reader.TestActivity import com.kairo.reader.core.model.BlinkMode import com.kairo.reader.core.model.RsvpConfig import com.kairo.reader.core.model.RsvpContextAssistMode +import com.kairo.reader.core.model.RsvpProfile 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.defaultConfig +import com.kairo.reader.core.model.withReaderPreferencesFrom import com.kairo.reader.ui.theme.KairoTheme import java.io.File import org.junit.Assert.assertEquals @@ -161,6 +164,40 @@ class RsvpDeviceSmokeTest { capture("playback-completed") } + @Test + fun changingPresetWhilePausedKeepsContextAndSourcePosition() { + val current = RsvpProfile.FOCUS.defaultConfig().copy( + contextAssistMode = RsvpContextAssistMode.FULL_CLAUSE, + blinkMode = BlinkMode.ADAPTIVE, + tempoMsPerWord = 250L, + ) + val fixture = RsvpDeviceFixture( + listOf(word("Earlier"), word("measurements"), word("contain"), word("uncertainty")), + current, + startIndex = 1, + ) + fixture.state = fixture.state.copy(initialIsPlaying = false) + show(fixture) + awaitLoaded(fixture) + for ((index, preset) in listOf(RsvpProfile.FLOW, RsvpProfile.STUDY, RsvpProfile.NARRATIVE).withIndex()) { + composeRule.runOnIdle { + fixture.state = fixture.state.copy( + profile = fixture.state.profile.copy( + config = preset.defaultConfig().withReaderPreferencesFrom(current), + ) + ) + } + awaitLoaded(fixture, minimumLoads = index + 2) + composeRule.onNodeWithText("measurements", useUnmergedTree = true).assertIsDisplayed() + capture("preset-${preset.name.lowercase()}") + } + click(R.string.content_desc_close) + composeRule.runOnIdle { + assertEquals(1, requireNotNull(fixture.saved).tokenIndex) + assertEquals(RsvpContextAssistMode.FULL_CLAUSE, fixture.state.profile.config.contextAssistMode) + } + } + private fun show(fixture: RsvpDeviceFixture) { composeRule.setContent { KairoTheme { 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 new file mode 100644 index 00000000..c1b6b22d --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt @@ -0,0 +1,58 @@ +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.RsvpConfig +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.ui.theme.KairoTheme +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Use the normal Android runtime, without Compose test-clock advancement, to measure exposure. */ +@RunWith(AndroidJUnit4::class) +class RsvpPunctuationTimingDeviceTest { + @Test + fun fasterLiveTempoKeepsThePunctuationHoldOnScreen() { + val fixture = RsvpDeviceFixture( + listOf(word("Before"), word("home"), Token(".", TokenType.PUNCTUATION), word("Next")), + RsvpConfig( + enablePhraseChunking = false, + tempoMsPerWord = 200L, + periodPauseMs = 1000L, + minPauseScale = 0.5, + useAdaptiveTiming = false, + usePunctuationLandingHold = false, + startDelayMs = 0L, + endDelayMs = 0L, + rampUpFrames = 0, + rampDownFrames = 0, + ), + ) + fixture.state = fixture.state.copy(launchTempoMsPerWord = 80L) + ActivityScenario.launch(TestActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.setContent { + KairoTheme { + RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) + } + } + } + val deadline = SystemClock.elapsedRealtime() + 10_000L + while (!fixture.finished && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20L) + assertTrue("Playback should complete", fixture.finished) + val index = fixture.consumed.indexOfFirst { frame -> frame.tokens.any { it.text == "home" } } + assertTrue(index > 0) + val hold = requireNotNull(fixture.consumed[index].punctuationHoldMs) + val displayedMs = fixture.consumedAtMs[index] - fixture.consumedAtMs[index - 1] + assertTrue("The test must exercise a full second of punctuation hold", hold >= 1000L) + assertTrue("Live tempo must preserve the hold: $displayedMs ms vs $hold ms", displayedMs >= hold) + } + } + + private fun word(text: String) = Token(text, TokenType.WORD) +} 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 78a9ca8d..3002dace 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 @@ -307,474 +307,6 @@ object RsvpProfileIds { data class RsvpCustomProfile(val id: String, val name: String, val config: RsvpConfig, val updatedAtMs: Long,) -// Built-in profiles are exhaustive immutable preset tables kept beside the domain enum. -@Suppress("LongMethod") -fun RsvpProfile.defaultConfig(): RsvpConfig = - when (this) { - RsvpProfile.BALANCED -> - RsvpConfig().copy( - tempoMsPerWord = 150L, - minWordMs = 48L, - longWordMinMs = 140L, - longWordChars = 10, - syllableExtraMs = 16L, - rarityExtraMaxMs = 68L, - complexityStrength = 0.68, - lengthStrength = 0.92, - lengthExponent = 1.35, - enablePhraseChunking = false, - maxWordsPerUnit = 2, - maxCharsPerUnit = 14, - contextAssistMode = RsvpContextAssistMode.OFF, - subwordChunkPauseMs = 60L, - dialogueMultiplier = 0.97, - smoothingAlpha = 0.30, - maxSpeedupFactor = 1.22, - maxSlowdownFactor = 1.42, - startDelayMs = 220L, - endDelayMs = 300L, - rampUpFrames = 4, - rampDownFrames = 3, - useAdaptiveTiming = true, - adaptiveDifficultyMaxHoldMs = 70L, - complexWordHoldMs = 45L, - useClausePausing = true, - useDialogueDetection = true, - complexWordThreshold = 1.30, - useProsodyPacing = true, - prosodyStrength = 1.06, - useFocalStress = true, - focalSupportCompression = 0.94, - blinkMode = BlinkMode.OFF, - ) - RsvpProfile.CHILL -> - RsvpConfig().copy( - tempoMsPerWord = 215L, - minWordMs = 64L, - longWordMinMs = 176L, - longWordChars = 9, - syllableExtraMs = 19L, - rarityExtraMaxMs = 82L, - complexityStrength = 0.76, - lengthStrength = 1.04, - lengthExponent = 1.32, - enablePhraseChunking = false, - maxWordsPerUnit = 2, - maxCharsPerUnit = 14, - contextAssistMode = RsvpContextAssistMode.OFF, - subwordChunkPauseMs = 76L, - dialogueMultiplier = 0.96, - smoothingAlpha = 0.20, - maxSpeedupFactor = 1.16, - maxSlowdownFactor = 1.72, - startDelayMs = 300L, - endDelayMs = 420L, - rampUpFrames = 6, - rampDownFrames = 4, - useAdaptiveTiming = true, - adaptiveDifficultyMaxHoldMs = 95L, - complexWordHoldMs = 60L, - useClausePausing = true, - useDialogueDetection = true, - complexWordThreshold = 1.22, - useProsodyPacing = true, - prosodyStrength = 1.28, - useFocalStress = true, - focalSupportCompression = 0.97, - blinkMode = BlinkMode.OFF, - ) - RsvpProfile.NARRATIVE -> - RsvpConfig().copy( - tempoMsPerWord = 148L, - minWordMs = 52L, - longWordMinMs = 150L, - longWordChars = 10, - syllableExtraMs = 16L, - rarityExtraMaxMs = 76L, - complexityStrength = 0.72, - lengthStrength = 0.96, - lengthExponent = 1.32, - enablePhraseChunking = true, - maxWordsPerUnit = 2, - maxCharsPerUnit = 14, - contextAssistMode = RsvpContextAssistMode.OFF, - subwordChunkPauseMs = 66L, - dialogueMultiplier = 0.91, - smoothingAlpha = 0.27, - maxSpeedupFactor = 1.20, - maxSlowdownFactor = 1.50, - startDelayMs = 260L, - endDelayMs = 340L, - rampUpFrames = 5, - rampDownFrames = 3, - useAdaptiveTiming = true, - adaptiveDifficultyMaxHoldMs = 80L, - complexWordHoldMs = 50L, - useClausePausing = true, - useDialogueDetection = true, - complexWordThreshold = 1.26, - useProsodyPacing = true, - prosodyStrength = 1.38, - useFocalStress = true, - focalSupportCompression = 0.92, - blinkMode = BlinkMode.OFF, - ) - RsvpProfile.FOCUS -> - RsvpConfig().copy( - tempoMsPerWord = 125L, - minWordMs = 48L, - longWordMinMs = 134L, - longWordChars = 10, - syllableExtraMs = 14L, - rarityExtraMaxMs = 58L, - complexityStrength = 0.60, - lengthStrength = 0.84, - lengthExponent = 1.25, - enablePhraseChunking = false, - maxWordsPerUnit = 2, - maxCharsPerUnit = 13, - contextAssistMode = RsvpContextAssistMode.OFF, - subwordChunkPauseMs = 52L, - dialogueMultiplier = 0.98, - smoothingAlpha = 0.32, - maxSpeedupFactor = 1.18, - maxSlowdownFactor = 1.30, - startDelayMs = 180L, - endDelayMs = 230L, - rampUpFrames = 3, - rampDownFrames = 2, - useAdaptiveTiming = true, - adaptiveDifficultyMaxHoldMs = 52L, - complexWordHoldMs = 32L, - useClausePausing = true, - useDialogueDetection = true, - complexWordThreshold = 1.36, - useProsodyPacing = true, - prosodyStrength = 0.88, - useFocalStress = true, - focalSupportCompression = 0.90, - blinkMode = BlinkMode.OFF, - ) - RsvpProfile.FLOW -> - RsvpConfig().copy( - tempoMsPerWord = 135L, - minWordMs = 50L, - longWordMinMs = 138L, - longWordChars = 10, - syllableExtraMs = 15L, - rarityExtraMaxMs = 64L, - complexityStrength = 0.64, - lengthStrength = 0.90, - lengthExponent = 1.30, - enablePhraseChunking = true, - maxWordsPerUnit = 2, - maxCharsPerUnit = 15, - contextAssistMode = RsvpContextAssistMode.OFF, - subwordChunkPauseMs = 58L, - dialogueMultiplier = 0.95, - smoothingAlpha = 0.28, - maxSpeedupFactor = 1.20, - maxSlowdownFactor = 1.34, - startDelayMs = 190L, - endDelayMs = 240L, - rampUpFrames = 4, - rampDownFrames = 2, - useAdaptiveTiming = true, - adaptiveDifficultyMaxHoldMs = 58L, - complexWordHoldMs = 36L, - useClausePausing = true, - useDialogueDetection = true, - complexWordThreshold = 1.34, - useProsodyPacing = true, - prosodyStrength = 1.12, - useFocalStress = true, - focalSupportCompression = 0.91, - blinkMode = BlinkMode.OFF, - ) - RsvpProfile.SPRINT -> - RsvpConfig().copy( - tempoMsPerWord = 105L, - minWordMs = 46L, - longWordMinMs = 128L, - longWordChars = 9, - syllableExtraMs = 18L, - rarityExtraMaxMs = 82L, - complexityStrength = 0.78, - lengthStrength = 0.98, - lengthExponent = 1.35, - enablePhraseChunking = false, - maxWordsPerUnit = 2, - maxCharsPerUnit = 12, - contextAssistMode = RsvpContextAssistMode.OFF, - subwordChunkPauseMs = 48L, - dialogueMultiplier = 0.99, - smoothingAlpha = 0.24, - maxSpeedupFactor = 1.14, - maxSlowdownFactor = 1.24, - startDelayMs = 140L, - endDelayMs = 180L, - rampUpFrames = 2, - rampDownFrames = 2, - useAdaptiveTiming = true, - adaptiveDifficultyMaxHoldMs = 50L, - complexWordHoldMs = 30L, - useClausePausing = true, - useDialogueDetection = true, - complexWordThreshold = 1.38, - useProsodyPacing = true, - prosodyStrength = 0.80, - useFocalStress = true, - focalSupportCompression = 0.88, - blinkMode = BlinkMode.OFF, - ) - RsvpProfile.STUDY -> - RsvpConfig().copy( - tempoMsPerWord = 190L, - minWordMs = 62L, - longWordMinMs = 192L, - longWordChars = 9, - syllableExtraMs = 22L, - rarityExtraMaxMs = 104L, - complexityStrength = 0.92, - lengthStrength = 1.12, - lengthExponent = 1.38, - enablePhraseChunking = true, - maxWordsPerUnit = 2, - maxCharsPerUnit = 14, - contextAssistMode = RsvpContextAssistMode.OFF, - subwordChunkPauseMs = 84L, - dialogueMultiplier = 0.96, - smoothingAlpha = 0.18, - maxSpeedupFactor = 1.12, - maxSlowdownFactor = 1.78, - startDelayMs = 320L, - endDelayMs = 440L, - rampUpFrames = 7, - rampDownFrames = 4, - useAdaptiveTiming = true, - adaptiveDifficultyMaxHoldMs = 110L, - complexWordHoldMs = 72L, - useClausePausing = true, - useDialogueDetection = true, - complexWordThreshold = 1.20, - useProsodyPacing = true, - prosodyStrength = 1.30, - useFocalStress = true, - focalSupportCompression = 0.96, - blinkMode = BlinkMode.OFF, - ) - }.withProfilePunctuation(this) - -private data class RsvpProfilePunctuationTuning( - val commaPauseMs: Long, - val periodPauseMs: Long, - val semicolonPauseMs: Long, - val colonPauseMs: Long, - val dashPauseMs: Long, - val parenthesesPauseMs: Long, - val quotePauseMs: Long, - val sentenceEndPauseMs: Long, - val paragraphPauseMs: Long, - val paragraphPauseMultiplier: Double, - val pageBreakPauseMultiplier: Double, - val pauseScaleExponent: Double, - val minPauseScale: Double, - val parentheticalMultiplier: Double, - val dialoguePunctuationScale: Double, - val clausePauseFactor: Double, - val punctuationPauseFactor: Double, - val anticipatoryLandingBoost: Double, - val parentheticalAsideMultiplier: Double, -) - -private fun RsvpConfig.withProfilePunctuation(profile: RsvpProfile): RsvpConfig = - with(profile.punctuationTuning()) { - copy( - commaPauseMs = commaPauseMs, - periodPauseMs = periodPauseMs, - semicolonPauseMs = semicolonPauseMs, - colonPauseMs = colonPauseMs, - dashPauseMs = dashPauseMs, - parenthesesPauseMs = parenthesesPauseMs, - quotePauseMs = quotePauseMs, - sentenceEndPauseMs = sentenceEndPauseMs, - paragraphPauseMs = paragraphPauseMs, - paragraphPauseMultiplier = paragraphPauseMultiplier, - pageBreakPauseMultiplier = pageBreakPauseMultiplier, - pauseScaleExponent = pauseScaleExponent, - minPauseScale = minPauseScale, - usePunctuationLandingHold = true, - parentheticalMultiplier = parentheticalMultiplier, - dialoguePunctuationScale = dialoguePunctuationScale, - clausePauseFactor = clausePauseFactor, - punctuationPauseFactor = punctuationPauseFactor, - useAnticipatoryLanding = true, - anticipatoryLandingBoost = anticipatoryLandingBoost, - useParentheticalAside = false, - parentheticalAsideMultiplier = parentheticalAsideMultiplier, - ) - } - -// Punctuation tuning is an exhaustive immutable preset table, not branching business logic. -@Suppress("LongMethod") -private fun RsvpProfile.punctuationTuning(): RsvpProfilePunctuationTuning = - when (this) { - RsvpProfile.BALANCED -> - RsvpProfilePunctuationTuning( - commaPauseMs = 170L, - periodPauseMs = 335L, - semicolonPauseMs = 270L, - colonPauseMs = 250L, - dashPauseMs = 270L, - parenthesesPauseMs = 170L, - quotePauseMs = 105L, - sentenceEndPauseMs = 360L, - paragraphPauseMs = 440L, - paragraphPauseMultiplier = 1.42, - pageBreakPauseMultiplier = 3.65, - pauseScaleExponent = 0.44, - minPauseScale = 0.84, - parentheticalMultiplier = 1.16, - dialoguePunctuationScale = 0.96, - clausePauseFactor = 1.32, - punctuationPauseFactor = 1.10, - anticipatoryLandingBoost = 1.10, - parentheticalAsideMultiplier = 0.94, - ) - RsvpProfile.CHILL -> - RsvpProfilePunctuationTuning( - commaPauseMs = 220L, - periodPauseMs = 480L, - semicolonPauseMs = 360L, - colonPauseMs = 330L, - dashPauseMs = 355L, - parenthesesPauseMs = 215L, - quotePauseMs = 145L, - sentenceEndPauseMs = 520L, - paragraphPauseMs = 660L, - paragraphPauseMultiplier = 1.62, - pageBreakPauseMultiplier = 4.20, - pauseScaleExponent = 0.52, - minPauseScale = 0.88, - parentheticalMultiplier = 1.24, - dialoguePunctuationScale = 0.98, - clausePauseFactor = 1.48, - punctuationPauseFactor = 1.18, - anticipatoryLandingBoost = 1.11, - parentheticalAsideMultiplier = 0.96, - ) - RsvpProfile.NARRATIVE -> - RsvpProfilePunctuationTuning( - commaPauseMs = 185L, - periodPauseMs = 385L, - semicolonPauseMs = 300L, - colonPauseMs = 280L, - dashPauseMs = 310L, - parenthesesPauseMs = 190L, - quotePauseMs = 155L, - sentenceEndPauseMs = 420L, - paragraphPauseMs = 525L, - paragraphPauseMultiplier = 1.52, - pageBreakPauseMultiplier = 3.90, - pauseScaleExponent = 0.44, - minPauseScale = 0.86, - parentheticalMultiplier = 1.20, - dialoguePunctuationScale = 0.96, - clausePauseFactor = 1.44, - punctuationPauseFactor = 1.16, - anticipatoryLandingBoost = 1.11, - parentheticalAsideMultiplier = 0.94, - ) - RsvpProfile.FOCUS -> - RsvpProfilePunctuationTuning( - commaPauseMs = 145L, - periodPauseMs = 305L, - semicolonPauseMs = 230L, - colonPauseMs = 215L, - dashPauseMs = 230L, - parenthesesPauseMs = 145L, - quotePauseMs = 85L, - sentenceEndPauseMs = 325L, - paragraphPauseMs = 380L, - paragraphPauseMultiplier = 1.34, - pageBreakPauseMultiplier = 3.35, - pauseScaleExponent = 0.40, - minPauseScale = 0.82, - parentheticalMultiplier = 1.12, - dialoguePunctuationScale = 0.94, - clausePauseFactor = 1.24, - punctuationPauseFactor = 1.06, - anticipatoryLandingBoost = 1.07, - parentheticalAsideMultiplier = 0.92, - ) - RsvpProfile.FLOW -> - RsvpProfilePunctuationTuning( - commaPauseMs = 158L, - periodPauseMs = 325L, - semicolonPauseMs = 250L, - colonPauseMs = 230L, - dashPauseMs = 250L, - parenthesesPauseMs = 158L, - quotePauseMs = 95L, - sentenceEndPauseMs = 350L, - paragraphPauseMs = 410L, - paragraphPauseMultiplier = 1.40, - pageBreakPauseMultiplier = 3.55, - pauseScaleExponent = 0.42, - minPauseScale = 0.84, - parentheticalMultiplier = 1.14, - dialoguePunctuationScale = 0.94, - clausePauseFactor = 1.30, - punctuationPauseFactor = 1.10, - anticipatoryLandingBoost = 1.09, - parentheticalAsideMultiplier = 0.92, - ) - RsvpProfile.SPRINT -> - RsvpProfilePunctuationTuning( - commaPauseMs = 125L, - periodPauseMs = 275L, - semicolonPauseMs = 215L, - colonPauseMs = 200L, - dashPauseMs = 215L, - parenthesesPauseMs = 128L, - quotePauseMs = 72L, - sentenceEndPauseMs = 295L, - paragraphPauseMs = 345L, - paragraphPauseMultiplier = 1.28, - pageBreakPauseMultiplier = 3.25, - pauseScaleExponent = 0.34, - minPauseScale = 0.86, - parentheticalMultiplier = 1.10, - dialoguePunctuationScale = 0.92, - clausePauseFactor = 1.20, - punctuationPauseFactor = 1.04, - anticipatoryLandingBoost = 1.07, - parentheticalAsideMultiplier = 0.90, - ) - RsvpProfile.STUDY -> - RsvpProfilePunctuationTuning( - commaPauseMs = 220L, - periodPauseMs = 510L, - semicolonPauseMs = 380L, - colonPauseMs = 350L, - dashPauseMs = 375L, - parenthesesPauseMs = 230L, - quotePauseMs = 160L, - sentenceEndPauseMs = 550L, - paragraphPauseMs = 700L, - paragraphPauseMultiplier = 1.68, - pageBreakPauseMultiplier = 4.35, - pauseScaleExponent = 0.54, - minPauseScale = 0.90, - parentheticalMultiplier = 1.28, - dialoguePunctuationScale = 1.00, - clausePauseFactor = 1.56, - punctuationPauseFactor = 1.22, - anticipatoryLandingBoost = 1.12, - parentheticalAsideMultiplier = 0.96, - ) - } - enum class TimedReadingMode { RSVP, BIONIC } data class BionicReadingPreferences( 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 6fe5cc18..b8bd089d 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 @@ -26,4 +26,8 @@ data class RsvpFrame( // A presentation gap, distinct from a structural pause or a source token. val isWordSeparation: Boolean = false, val isRepeatedWordSeparation: Boolean = false, + // Part of durationMs reserved for settling on the visible word at punctuation. + // 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, ) 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 new file mode 100644 index 00000000..1ef9451f --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/model/RsvpProfileDefaults.kt @@ -0,0 +1,247 @@ +package com.kairo.reader.core.model + +/** Reading styles share safety floors; each profile changes cadence rather than display preferences. */ +fun RsvpProfile.defaultConfig(): RsvpConfig = when (this) { + RsvpProfile.BALANCED -> naturalReadingDefaults() + RsvpProfile.CHILL -> chillReadingDefaults() + RsvpProfile.NARRATIVE -> narrativeReadingDefaults() + RsvpProfile.FOCUS -> focusReadingDefaults() + RsvpProfile.FLOW -> flowReadingDefaults() + RsvpProfile.SPRINT -> skimReadingDefaults() + RsvpProfile.STUDY -> studyReadingDefaults() +} + +private fun naturalReadingDefaults() = RsvpConfig( + tempoMsPerWord = 155L, + minWordMs = 55L, + longWordMinMs = 160L, + longWordChars = 12, + maxChunkLength = 24, + syllableExtraMs = 14L, + rarityExtraMaxMs = 55L, + complexityStrength = 0.55, + lengthStrength = 0.85, + lengthExponent = 1.30, + enablePhraseChunking = true, + maxWordsPerUnit = 2, + maxCharsPerUnit = 14, + subwordChunkPauseMs = 45L, + commaPauseMs = 190L, + periodPauseMs = 380L, + semicolonPauseMs = 330L, + colonPauseMs = 300L, + dashPauseMs = 290L, + parenthesesPauseMs = 150L, + quotePauseMs = 80L, + sentenceEndPauseMs = 410L, + paragraphPauseMs = 450L, + paragraphPauseMultiplier = 1.35, + pageBreakPauseMultiplier = 3.20, + pauseScaleExponent = 0.44, + minPauseScale = 0.84, + punctuationPauseFactor = 1.0, + parentheticalMultiplier = 1.08, + dialogueMultiplier = 1.0, + dialoguePunctuationScale = 1.0, + smoothingAlpha = 0.30, + maxSpeedupFactor = 1.18, + maxSlowdownFactor = 1.38, + startDelayMs = 240L, + endDelayMs = 300L, + rampUpFrames = 4, + rampDownFrames = 3, + adaptiveDifficultyMaxHoldMs = 60L, + complexWordHoldMs = 40L, + clausePauseFactor = 1.20, + prosodyStrength = 0.80, + focalSupportCompression = 0.96, + anticipatoryLandingBoost = 1.04, + parentheticalAsideMultiplier = 0.95, +) + +/** Space between thoughts, with restrained movement inside each phrase. */ +private fun chillReadingDefaults() = naturalReadingDefaults().copy( + tempoMsPerWord = 215L, + minWordMs = 70L, + longWordMinMs = 200L, + rarityExtraMaxMs = 50L, + complexityStrength = 0.50, + lengthStrength = 0.80, + smoothingAlpha = 0.18, + maxSpeedupFactor = 1.12, + maxSlowdownFactor = 1.28, + commaPauseMs = 205L, + semicolonPauseMs = 380L, + colonPauseMs = 350L, + dashPauseMs = 335L, + periodPauseMs = 460L, + sentenceEndPauseMs = 495L, + paragraphPauseMs = 650L, + paragraphPauseMultiplier = 1.50, + pageBreakPauseMultiplier = 3.30, + prosodyStrength = 0.50, + focalSupportCompression = 0.98, + anticipatoryLandingBoost = 1.03, + startDelayMs = 400L, + rampUpFrames = 6, + endDelayMs = 400L, +) + +/** A gentle spoken contour, without routinely rushing quoted speech. */ +private fun narrativeReadingDefaults() = naturalReadingDefaults().copy( + minWordMs = 60L, + longWordMinMs = 170L, + maxCharsPerUnit = 18, + prosodyStrength = 1.15, + dialogueMultiplier = 0.98, + quotePauseMs = 130L, + semicolonPauseMs = 340L, + dashPauseMs = 320L, + periodPauseMs = 420L, + sentenceEndPauseMs = 450L, + paragraphPauseMs = 560L, + paragraphPauseMultiplier = 1.45, + pageBreakPauseMultiplier = 3.50, + focalSupportCompression = 0.94, + anticipatoryLandingBoost = 1.07, + smoothingAlpha = 0.32, + maxSpeedupFactor = 1.20, + maxSlowdownFactor = 1.45, +) + +/** Predictable single-word presentation with safety and punctuation still intact. */ +private fun focusReadingDefaults() = naturalReadingDefaults().copy( + minWordMs = 65L, + longWordMinMs = 165L, + enablePhraseChunking = false, + smoothingAlpha = 0.16, + maxSpeedupFactor = 1.10, + maxSlowdownFactor = 1.22, + useProsodyPacing = false, + prosodyStrength = 0.0, + useFocalStress = false, + focalSupportCompression = 1.0, + useAnticipatoryLanding = false, + anticipatoryLandingBoost = 1.0, + useDialogueDetection = false, + rarityExtraMaxMs = 50L, + commaPauseMs = 155L, + periodPauseMs = 345L, + sentenceEndPauseMs = 370L, + paragraphPauseMs = 430L, +) + +/** Longer coherent units, with light transitions between them. */ +private fun flowReadingDefaults() = naturalReadingDefaults().copy( + longWordMinMs = 150L, + maxWordsPerUnit = 3, + maxCharsPerUnit = 22, + smoothingAlpha = 0.35, + maxSpeedupFactor = 1.20, + maxSlowdownFactor = 1.34, + prosodyStrength = 0.65, + focalSupportCompression = 0.97, + anticipatoryLandingBoost = 1.03, + commaPauseMs = 160L, + periodPauseMs = 345L, + sentenceEndPauseMs = 375L, + paragraphPauseMs = 430L, + paragraphPauseMultiplier = 1.30, + rarityExtraMaxMs = 50L, + complexityStrength = 0.50, + lengthStrength = 0.80, +) + +/** Efficient review: reduce ornament, never omit source words or punctuation floors. */ +private fun skimReadingDefaults() = naturalReadingDefaults().copy( + tempoMsPerWord = 125L, + longWordMinMs = 150L, + maxCharsPerUnit = 16, + syllableExtraMs = 10L, + rarityExtraMaxMs = 30L, + complexityStrength = 0.35, + lengthStrength = 0.65, + lengthExponent = 1.20, + adaptiveDifficultyMaxHoldMs = 25L, + complexWordHoldMs = 20L, + complexWordThreshold = 1.50, + useProsodyPacing = false, + prosodyStrength = 0.0, + useFocalStress = false, + focalSupportCompression = 1.0, + useAnticipatoryLanding = false, + anticipatoryLandingBoost = 1.0, + useDialogueDetection = false, + commaPauseMs = 125L, + semicolonPauseMs = 255L, + colonPauseMs = 235L, + dashPauseMs = 225L, + parenthesesPauseMs = 110L, + quotePauseMs = 65L, + periodPauseMs = 290L, + sentenceEndPauseMs = 315L, + paragraphPauseMs = 360L, + paragraphPauseMultiplier = 1.20, + pageBreakPauseMultiplier = 3.0, + minPauseScale = 0.82, + startDelayMs = 200L, + rampUpFrames = 3, + endDelayMs = 250L, +) + +/** Protect unfamiliar terms and completed ideas, without expressive compression. */ +private fun studyReadingDefaults() = naturalReadingDefaults().copy( + tempoMsPerWord = 185L, + minWordMs = 80L, + longWordMinMs = 240L, + longWordChars = 10, + maxChunkLength = 32, + enablePhraseChunking = false, + maxCharsPerUnit = 16, + syllableExtraMs = 22L, + rarityExtraMaxMs = 100L, + complexityStrength = 0.95, + lengthStrength = 1.05, + lengthExponent = 1.35, + adaptiveDifficultyMaxHoldMs = 100L, + complexWordHoldMs = 65L, + complexWordThreshold = 1.20, + prosodyStrength = 0.60, + useFocalStress = false, + focalSupportCompression = 1.0, + useAnticipatoryLanding = false, + anticipatoryLandingBoost = 1.0, + useDialogueDetection = false, + smoothingAlpha = 0.30, + maxSpeedupFactor = 1.10, + maxSlowdownFactor = 1.75, + commaPauseMs = 205L, + semicolonPauseMs = 420L, + colonPauseMs = 440L, + dashPauseMs = 370L, + periodPauseMs = 505L, + sentenceEndPauseMs = 550L, + paragraphPauseMs = 680L, + paragraphPauseMultiplier = 1.45, + pageBreakPauseMultiplier = 3.50, + startDelayMs = 350L, + rampUpFrames = 6, + endDelayMs = 450L, +) + +/** Speed and visual aids belong to the reader, independently of the selected cadence. */ +fun RsvpConfig.withReaderPreferencesFrom(current: RsvpConfig): RsvpConfig = + copy( + tempoMsPerWord = current.tempoMsPerWord, + baseWpm = current.baseWpm, + contextAssistMode = current.contextAssistMode, + blinkMode = current.blinkMode, + orpEnabled = current.orpEnabled, + orpHighlightEnabled = current.orpHighlightEnabled, + orpGuideEnabled = current.orpGuideEnabled, + orpGuideBrightness = current.orpGuideBrightness, + orpGuideThickness = current.orpGuideThickness, + ) + +/** Compare only the settings a reading preset owns. */ +fun RsvpConfig.profileCadenceIdentity(): RsvpConfig = withReaderPreferencesFrom(RsvpConfig()) diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt index 128d09ba..f5874b83 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 @@ -46,11 +46,11 @@ import com.kairo.reader.core.rsvp.text.isOpeningPunctuation import com.kairo.reader.core.rsvp.timing.RsvpPunctuationTier import com.kairo.reader.core.rsvp.timing.RsvpPunctuationTimingPolicy import com.kairo.reader.core.rsvp.timing.RsvpUnitTimingInput -import com.kairo.reader.core.rsvp.timing.computeUnitDurationMs +import com.kairo.reader.core.rsvp.timing.computeUnitTiming +import com.kairo.reader.core.rsvp.timing.expressionStrength import com.kairo.reader.core.rsvp.timing.pageBreakBasePauseMs import com.kairo.reader.core.rsvp.timing.paragraphBreakBasePauseMs import com.kairo.reader.core.rsvp.timing.pauseScale -import com.kairo.reader.core.rsvp.timing.speedStrength import kotlin.math.max interface RsvpEngine { @@ -405,8 +405,8 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { val unitCues = (wordCursor until nextCursor).mapNotNull { analysis.thoughtCues[it] } - val durationMs = - computeUnitDurationMs( + val timing = + computeUnitTiming( RsvpUnitTimingInput( frameTokens = frameTokens, config = config, @@ -437,7 +437,8 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { frames += RsvpFrame( tokens = frameTokens, - durationMs = durationMs, + durationMs = timing.durationMs, + punctuationHoldMs = timing.punctuationHoldMs, originalTokenIndex = frameOriginalIndex, resumeCursor = RsvpResumeCursor.fromCharacterOffset( @@ -495,7 +496,7 @@ private fun RsvpGenerationContext.focalSuppression(wordCursor: Int): Double { !shouldKeepFullFocalDuration(word) ) { val target = config.focalSupportCompression.coerceIn(MIN_FOCAL_SUPPORT_COMPRESSION, 1.0) - val compressionStrength = speedStrength(config.tempoMsPerWord.toDouble()) + val compressionStrength = expressionStrength(config.tempoMsPerWord.toDouble()) 1.0 + ((target - 1.0) * compressionStrength) } else { 1.0 diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEstimatedReadingPace.kt b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEstimatedReadingPace.kt index 716685c7..42c39445 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEstimatedReadingPace.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEstimatedReadingPace.kt @@ -131,12 +131,7 @@ object RsvpEstimatedReadingPace { if (shouldSkipBlinkFrame(frame, config, effectiveTempoMsPerWord, tempoScale)) { return@forEach } - val scaledMs = - (frame.durationMs * tempoScale) - .roundToLong() - .coerceAtLeast(MIN_FRAME_DELAY_MS) - val floorMs = frameFloorMs(frame, config, effectiveTempoMsPerWord) - totalMs += max(scaledMs, floorMs) + totalMs += scaledFrameDurationMs(frame, config, effectiveTempoMsPerWord, tempoScale) } if (wordCount <= 0 || totalMs <= 0L) { @@ -219,6 +214,19 @@ private fun nextSingleWordOriginalIndex( } } +/** Keep the punctuation hold on its source frame when changing live speed. */ +internal fun scaledFrameDurationMs( + frame: RsvpFrame, + config: RsvpConfig, + effectiveTempoMs: Long, + tempoScale: Double, +): Long { + val hold = (frame.punctuationHoldMs ?: 0L).coerceIn(0L, frame.durationMs.coerceAtLeast(0L)) + val readingMs = (frame.durationMs - hold).coerceAtLeast(0L) + val scaled = (readingMs * tempoScale + hold).roundToLong().coerceAtLeast(MIN_FRAME_DELAY_MS) + return max(scaled, frameFloorMs(frame, config, effectiveTempoMs)) +} + internal fun frameFloorMs( frame: RsvpFrame, config: RsvpConfig, @@ -231,12 +239,17 @@ internal fun frameFloorMs( if (firstWordIndex == -1) return frame.durationMs val effectiveConfig = config.copy(tempoMsPerWord = effectiveTempoMs) - var total = 0L + // Generated frames already know whether a dot is an abbreviation or a sentence ending. + // Reclassifying without their lookahead could turn "Dr." into a full stop at playback time. + var total = frame.punctuationHoldMs?.coerceAtLeast(0L) ?: 0L tokens.forEachIndexed { index, token -> when (token.type) { TokenType.WORD -> total += config.wordFloorMsForReadability(token, effectiveTempoMs) - TokenType.PUNCTUATION -> - total += punctuationFloorMs(tokens, token, index, firstWordIndex, effectiveConfig) + TokenType.PUNCTUATION -> { + if (frame.punctuationHoldMs == null) { + total += punctuationFloorMs(tokens, token, index, firstWordIndex, effectiveConfig) + } + } TokenType.PARAGRAPH_BREAK, TokenType.PAGE_BREAK -> Unit } } 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 132f6806..980b480e 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 @@ -64,7 +64,7 @@ internal const val EM_DASH_INTERRUPTION_PAUSE_FACTOR = 0.45 // (factor > 1). Applied to the sentence-end punctuation pause only. internal const val SENTENCE_WRAP_UP_SHORT_WORDS = 6.0 internal const val SENTENCE_WRAP_UP_LONG_WORDS = 22.0 -internal const val SENTENCE_WRAP_UP_MIN_FACTOR = 0.90 +internal const val SENTENCE_WRAP_UP_MIN_FACTOR = 1.0 internal const val SENTENCE_WRAP_UP_MAX_FACTOR = 1.18 internal const val FUNCTION_BRIDGE_HOLD_MS = 2.0 internal const val CLAUSE_START_HOLD_FRACTION = 0.18 @@ -108,11 +108,9 @@ internal const val PHRASE_CONTOUR_WORD_WINDOW = 2 internal const val SENTENCE_PRE_BOUNDARY_CONTOUR = 0.07 internal const val SENTENCE_ANTICIPATORY_CONTOUR = 0.04 internal const val SENTENCE_RESTART_CONTOUR = 0.08 -internal const val SENTENCE_CONTOUR_PAUSE_RETAINED = 0.94 internal const val CLAUSE_PRE_BOUNDARY_CONTOUR = 0.035 internal const val CLAUSE_ANTICIPATORY_CONTOUR = 0.018 internal const val CLAUSE_RESTART_CONTOUR = 0.04 -internal const val CLAUSE_CONTOUR_PAUSE_RETAINED = 0.96 internal const val PARAGRAPH_BREAK_RETENTION_BOOST = 0.22 internal const val PARAGRAPH_SENTENCE_MULTIPLIER = 0.8 internal const val PAGE_BREAK_RETENTION_BOOST = 0.32 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 37539ec3..14936cd3 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 @@ -62,7 +62,7 @@ private fun splitFrameForBlink( if ((!repeatedWord && shouldHold) || isHardBoundary(frame.tokens, nextWord)) return listOf(frame) val floorMs = max(wordFloorMs(firstWord, config), MIN_FRAME_MS) - val maxBlink = (frame.durationMs - floorMs).coerceAtLeast(0L) + val maxBlink = (frame.durationMs - floorMs - (frame.punctuationHoldMs ?: 0L)).coerceAtLeast(0L) val punctuationFactor = blinkPunctuationFactor(frame.tokens) val blinkMs = min((WORD_SEPARATION_MS * punctuationFactor).roundToLong(), maxBlink) return if (blinkMs < MIN_BLINK_MS) { @@ -73,6 +73,7 @@ private fun splitFrameForBlink( RsvpFrame( tokens = listOf(blinkToken), isWordSeparation = true, + punctuationHoldMs = 0L, isRepeatedWordSeparation = repeatedWord, durationMs = blinkMs, originalTokenIndex = frame.originalTokenIndex, diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/text/RsvpTextRules.kt b/app/src/main/java/com/kairo/reader/core/rsvp/text/RsvpTextRules.kt index 531b0429..e50f7f6f 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/text/RsvpTextRules.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/text/RsvpTextRules.kt @@ -33,12 +33,6 @@ internal fun isClauseLeadPunctuation( private val CLAUSE_LEAD_PUNCTUATION = setOf(',', ';', ':', '\u2014', '\u2013', '-') -internal fun isLikelySentenceContinuation(nextToken: Token?): Boolean { - val nextWord = nextToken?.takeIf { it.type == TokenType.WORD } ?: return false - val firstChar = nextWord.text.firstOrNull() ?: return false - return firstChar.isLowerCase() -} - internal fun isEmbeddedQuote( ch: Char, prevWord: Token?, @@ -307,23 +301,16 @@ internal fun isAbbreviationDot( val isSentenceStarter = nextWord.lowercase() in SENTENCE_STARTERS val nextIsInitial = nextLetters.length == 1 && nextLetters.all { it.isUpperCase() } + // "No." is an abbreviation before a number, but a complete answer before prose. + if (normalized == "no") return nextWord.firstOrNull()?.isDigit() == true val prevLetters = rawPrev.filter { it.isLetter() } - val canContinueAbbreviation = nextStartsLower || (nextStartsUpper && !isSentenceStarter) || nextIsInitial - val abbreviationShape = - prevLetters.isNotEmpty() && - ( - prevLetters.length == 1 || - ( - prevLetters.length <= MAX_UPPERCASE_ABBREVIATION_LENGTH && - prevLetters.all(Char::isUpperCase) - ) - ) + val canContinueAbbreviation = !isSentenceStarter && (nextStartsLower || nextStartsUpper || nextIsInitial) + val personalInitial = prevLetters.length == 1 && prevLetters.all(Char::isUpperCase) && nextStartsUpper return normalized in TITLE_ABBREVIATIONS || - (canContinueAbbreviation && (normalized in KNOWN_ABBREVIATIONS || abbreviationShape)) + (canContinueAbbreviation && (normalized in KNOWN_ABBREVIATIONS || personalInitial)) } private const val THOUSANDS_GROUP_DIGITS = 3 -private const val MAX_UPPERCASE_ABBREVIATION_LENGTH = 3 internal fun isRhythmBoundaryPunctuation( token: Token, diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpPunctuationTimingPolicy.kt b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpPunctuationTimingPolicy.kt index 117c01b4..61faa2c8 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpPunctuationTimingPolicy.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpPunctuationTimingPolicy.kt @@ -6,11 +6,9 @@ import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType import com.kairo.reader.core.model.isMidSentencePunctuation import com.kairo.reader.core.model.isSentenceEndingPunctuation -import com.kairo.reader.core.model.speedNarrowingFactor import com.kairo.reader.core.rsvp.text.isAbbreviationDot import com.kairo.reader.core.rsvp.text.isClauseLeadPunctuation import com.kairo.reader.core.rsvp.text.isDecimalPoint -import com.kairo.reader.core.rsvp.text.isLikelySentenceContinuation import com.kairo.reader.core.rsvp.text.isThousandSeparator import kotlin.math.max import kotlin.math.min @@ -63,13 +61,10 @@ internal object RsvpPunctuationTimingPolicy { scaleRetentionBoost(ch = ch, tier = tier, nextToken = nextToken) val breathingScale = punctuationBreathingScale(config) - val rawSpeedScale = config.speedNarrowingFactor(config.tempoMsPerWord) - val speedScale = - 1.0 - ((1.0 - rawSpeedScale) * PUNCTUATION_NARROWING_STRENGTH) return RsvpPunctuationPauseTiming( - baseMs = base * breathingScale * speedScale, - floorMs = floor * breathingScale * speedScale, + baseMs = base * breathingScale, + floorMs = floor * breathingScale, scaleRetentionBoost = scaleRetentionBoost, ) } @@ -176,7 +171,6 @@ internal object RsvpPunctuationTimingPolicy { when { character == '.' && (isDecimalPoint(previousText, nextToken) || isAbbreviationDot(previousText, nextToken)) -> 0.0 - isLikelySentenceContinuation(nextToken) -> PERIOD_CONTINUATION_TAIL_LIFT else -> PERIOD_TAIL_LIFT } @@ -195,7 +189,6 @@ internal object RsvpPunctuationTimingPolicy { when { ch == '.' && (isDecimalPoint(prevText, nextToken) || isAbbreviationDot(prevText, nextToken)) -> 0.0 - isLikelySentenceContinuation(nextToken) -> PERIOD_CONTINUATION_CONTOUR else -> PERIOD_CONTOUR } } @@ -344,7 +337,6 @@ internal object RsvpPunctuationTimingPolicy { when { ch == '.' && (isDecimalPoint(prevText, nextToken) || isAbbreviationDot(prevText, nextToken)) -> null - isLikelySentenceContinuation(nextToken) -> sentenceContinuationPauseMs(config) ch == '.' -> config.periodPauseMs.toDouble() else -> max(config.periodPauseMs, config.sentenceEndPauseMs).toDouble() } @@ -469,12 +461,6 @@ internal object RsvpPunctuationTimingPolicy { private fun isExclamationPunctuation(ch: Char): Boolean = ch in EXCLAMATION_PUNCTUATION - private fun sentenceContinuationPauseMs(config: RsvpConfig): Double = - max( - config.commaPauseMs * SENTENCE_CONTINUATION_COMMA_FACTOR, - config.periodPauseMs * SENTENCE_CONTINUATION_PERIOD_FACTOR, - ).coerceAtMost(config.periodPauseMs.toDouble()) - internal fun resolveTier( token: Token, prevWord: Token?, @@ -510,12 +496,9 @@ internal object RsvpPunctuationTimingPolicy { private const val PARENTHESIS_RETENTION_BOOST = 0.08 private const val SEMICOLON_RETENTION_BOOST = 0.18 private const val ELLIPSIS_RETENTION_BOOST = 0.26 - private const val PUNCTUATION_NARROWING_STRENGTH = 0.35 private const val COMMA_BREATH_FACTOR = 1.10 private const val CLAUSE_LEADING_COMMA_FACTOR = 1.16 private const val SOFT_SEPARATOR_COMMA_FACTOR = 0.35 - private const val SENTENCE_CONTINUATION_COMMA_FACTOR = 1.25 - private const val SENTENCE_CONTINUATION_PERIOD_FACTOR = 0.64 private const val QUESTION_PAUSE_FACTOR = 1.08 private const val EXCLAMATION_PAUSE_FACTOR = 0.96 private const val CLAUSE_LANDING_HOLD_WEIGHT = 0.18 @@ -523,7 +506,6 @@ internal object RsvpPunctuationTimingPolicy { private const val STRONG_LANDING_HOLD_WEIGHT = 0.22 private const val ELLIPSIS_LANDING_HOLD_WEIGHT = 0.24 - private const val PERIOD_CONTINUATION_TAIL_LIFT = 1.18 private const val PERIOD_TAIL_LIFT = 1.34 private const val ELLIPSIS_TAIL_LIFT = 1.40 private const val QUESTION_TAIL_LIFT = 1.34 @@ -535,7 +517,6 @@ internal object RsvpPunctuationTimingPolicy { private const val CLAUSE_LEAD_COMMA_TAIL_LIFT = 0.36 private const val COMMA_TAIL_LIFT = 0.24 - private const val PERIOD_CONTINUATION_CONTOUR = 0.55 private const val PERIOD_CONTOUR = 0.92 private const val QUESTION_CONTOUR = 0.94 private const val EXCLAMATION_CONTOUR = 0.84 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 98b03a37..51f69d17 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 @@ -12,7 +12,6 @@ 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 import com.kairo.reader.core.rsvp.engine.CLAUSE_BOUNDARY_HOLD_MS -import com.kairo.reader.core.rsvp.engine.CLAUSE_CONTOUR_PAUSE_RETAINED import com.kairo.reader.core.rsvp.engine.CLAUSE_LEAD_BOOST_MS import com.kairo.reader.core.rsvp.engine.CLAUSE_START_HOLD_FRACTION import com.kairo.reader.core.rsvp.engine.CLAUSE_START_MIN_HOLD_MS @@ -27,7 +26,6 @@ import com.kairo.reader.core.rsvp.engine.PAGE_BREAK_SENTENCE_MULTIPLIER_RATIO import com.kairo.reader.core.rsvp.engine.PARAGRAPH_SENTENCE_MULTIPLIER import com.kairo.reader.core.rsvp.engine.PARENTHETICAL_HOLD_FRACTION import com.kairo.reader.core.rsvp.engine.PHRASE_BREAK_HOLD_MS -import com.kairo.reader.core.rsvp.engine.SENTENCE_CONTOUR_PAUSE_RETAINED import com.kairo.reader.core.rsvp.engine.SENTENCE_END_BREAK_BOOST_MS import com.kairo.reader.core.rsvp.engine.SENTENCE_START_MIN_HOLD_MS import com.kairo.reader.core.rsvp.engine.SENTENCE_WRAP_UP_LONG_WORDS @@ -175,16 +173,6 @@ internal fun punctuationPauseMs( val timing = RsvpPunctuationTimingPolicy.resolvePauseTiming(token, prevWord, nextToken, config) var base = timing.baseMs var floor = timing.floorMs - val tier = - RsvpPunctuationTimingPolicy.resolveTier( - token = token, - prevWord = prevWord, - nextToken = nextToken, - ) - if (prevWord != null && nextToken?.type == TokenType.WORD) { - base *= phraseContourPauseRedistributionFactor(tier) - } - val speedStrength = speedStrength(msPerWord) if (isClauseLeadPunctuation(ch, nextToken)) { base += CLAUSE_LEAD_BOOST_MS * speedStrength @@ -231,13 +219,6 @@ internal fun punctuationPauseMs( return max(scaled, floor) } -internal fun phraseContourPauseRedistributionFactor(tier: RsvpPunctuationTier): Double = - when (tier) { - RsvpPunctuationTier.SENTENCE_END -> SENTENCE_CONTOUR_PAUSE_RETAINED - RsvpPunctuationTier.CLAUSE_BREAK -> CLAUSE_CONTOUR_PAUSE_RETAINED - RsvpPunctuationTier.SOFT_SEPARATOR, RsvpPunctuationTier.NONE -> 1.0 - } - internal fun punctuationLandingHoldMs( frameTokens: List, nextToken: Token?, @@ -380,7 +361,7 @@ internal fun startBoostMultiplier( /** * Sentence wrap-up: scales the sentence-end pause by how many words the sentence held. - * Short sentences turn over briskly; long sentences earn a fuller integration stop. + * Every sentence keeps its full stop; longer sentences earn additional integration time. */ internal fun sentenceWrapUpFactor(wordsInSentence: Int): Double { val t = @@ -399,6 +380,12 @@ internal fun speedStrength(msPerWord: Double): Double { return ((speedFactor - 1.0) / SPEED_STRENGTH_FACTOR_RANGE).coerceIn(0.0, 1.0) } +// Expression remains audible at comfortable tempos; speed-only compensation still starts at 300 WPM. +internal fun expressionStrength(msPerWord: Double): Double = + BASE_EXPRESSION_STRENGTH + (1.0 - BASE_EXPRESSION_STRENGTH) * speedStrength(msPerWord) + +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 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 136ca897..3cf64d5c 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 @@ -120,6 +120,7 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { val pageBreaks = frameTokens.count { it.type == TokenType.PAGE_BREAK } val firstWordIndex = frameTokens.indexOfFirst { it.type == TokenType.WORD } val speedStrength = speedStrength(msPerWord) + val expressionStrength = expressionStrength(msPerWord) val prosodyStrength = if (config.useProsodyPacing) { config.prosodyStrength.coerceIn( @@ -153,7 +154,7 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { (config.clausePauseFactor - 1.0) / (DEFAULT_CLAUSE_PAUSE_FACTOR - 1.0) ).coerceIn(0.0, 2.0) - val dialogueEntryBoost = 1.0 + (DIALOGUE_ENTRY_BOOST * speedStrength) + val dialogueEntryBoost = 1.0 + (DIALOGUE_ENTRY_BOOST * expressionStrength) // Phrase-arc shaping at grammatical boundaries: single-word, punctuation-free frames only, // mirroring the breath in transitionHoldMs (punctuation frames are shaped by the contour @@ -169,7 +170,7 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { prevWord = prevWord, nextWord = nextWord, boundaryBefore = boundaryForBoost, - speedStrength = speedStrength, + speedStrength = expressionStrength, prosodyStrength = prosodyStrength, ) } else { @@ -258,7 +259,7 @@ private fun wordDurationContribution( val inAside = config.useParentheticalAside && (state.parentheticalDepth > 0 || context.emDashAside) val dialogueMultiplier = if (config.useDialogueDetection && state.inDialogue) { - contextShapingMultiplier(config.dialogueMultiplier, context.speedStrength) + contextShapingMultiplier(config.dialogueMultiplier, context.expressionStrength) } else { 1.0 } @@ -310,7 +311,7 @@ private fun wordDurationContribution( nextWordText, index == context.firstWordIndex, context.boundaryBefore, - context.speedStrength, + context.expressionStrength, context.prosodyStrength, ) val dialogueEntry = @@ -343,7 +344,7 @@ private fun wordDurationContribution( dialogueEntry, if (protectedEmphasis) 1.0 else context.focalSuppression, context.anticipatoryLanding, - phraseContourMultiplier(context.phraseContour, context.speedStrength), + 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, @@ -446,12 +447,23 @@ private fun punctuationPauseForToken( if (context.config.useAdaptiveTiming) pause *= sentenceWrapUpFactor(context.prose.wordsInSentence) context.prose.onSentenceEnd() } - return pause + // Strong boundaries retain their floor after aside and sentence shaping. + // Embedded quotes keep their deliberately lighter separator timing. + val floor = if (tier == RsvpPunctuationTier.SENTENCE_END || tier == RsvpPunctuationTier.CLAUSE_BREAK) { + RsvpPunctuationTimingPolicy.resolvePauseTiming(token, previousWord, nextWord, context.config).floorMs + } else { + 0.0 + } + return max(pause, floor) } private val CLOSING_ASIDE_PUNCTUATION = setOf(')', ']', '}') -internal fun computeUnitDurationMs(input: RsvpUnitTimingInput): Long = +internal data class RsvpUnitTiming(val durationMs: Long, val punctuationHoldMs: 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 @@ -498,7 +510,7 @@ internal fun computeUnitDurationMs(input: RsvpUnitTimingInput): Long = smoothedWordDuration * expression, words.sumOf { wordFloorMs(it, config).toDouble() }, ) - totalDuration += thoughtCues.sumOf { it.integrationHoldMs } + var integrationHold = thoughtCues.sumOf { it.integrationHoldMs } if (words.isNotEmpty()) { when (boundaryForBoost) { BoundaryBefore.SENTENCE -> { @@ -522,15 +534,18 @@ internal fun computeUnitDurationMs(input: RsvpUnitTimingInput): Long = boundaryBefore = boundaryForBoost, ) } - totalDuration += punctuationPauseDuration(this) + val punctuationHold = punctuationPauseDuration(this) + totalDuration += punctuationHold if (config.usePunctuationLandingHold) { - totalDuration += + integrationHold = max( + integrationHold, punctuationLandingHoldMs( frameTokens = frameTokens, nextToken = nextToken, msPerWord = msPerWord, speedStrength = speedStrength, - ) + ), + ) } if (paragraphBreaks > 0) { totalDuration += config.paragraphPauseMs * paragraphPauseScale * paragraphBreaks @@ -556,10 +571,15 @@ internal fun computeUnitDurationMs(input: RsvpUnitTimingInput): Long = config.useClausePausing && nextWord?.isClauseBoundary == true ) { - totalDuration += - config.commaPauseMs * pauseScale * CLAUSE_LEAD_HOLD_FRACTION * clauseConfigStrength + integrationHold = max( + integrationHold, + config.commaPauseMs * pauseScale * CLAUSE_LEAD_HOLD_FRACTION * clauseConfigStrength, + ) } } + // Landing, thought integration, and clause anticipation describe the same breath. + // Preserve the strongest cue while explicit punctuation and difficulty holds remain additive. + totalDuration += integrationHold if (config.useDialogueDetection && (wordTiming.enteredDialogue || wordTiming.exitedDialogue)) { val quoteHold = config.quotePauseMs * pauseScale * QUOTE_TRANSITION_HOLD_FRACTION if (wordTiming.enteredDialogue) totalDuration += quoteHold @@ -574,7 +594,8 @@ internal fun computeUnitDurationMs(input: RsvpUnitTimingInput): Long = totalDuration += parentheticalHoldMs(msPerWord = msPerWord, config = config) } - return totalDuration - .toLong() - .coerceAtLeast(MIN_FRAME_MS) + return RsvpUnitTiming( + durationMs = totalDuration.toLong().coerceAtLeast(MIN_FRAME_MS), + 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 3a22a20f..744dd9eb 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 @@ -10,7 +10,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey internal object PrefKeys { val tempoMsPerWord = longPreferencesKey("tempo_ms_per_word") val rsvpSpeedCurveVersion = intPreferencesKey("rsvp_speed_curve_version") - val rsvpPunctuationTuningVersion = intPreferencesKey("rsvp_punctuation_tuning_version") + val rsvpPresetVersion = intPreferencesKey("rsvp_preset_version") val rsvpProfile = stringPreferencesKey("rsvp_profile") val customRsvpProfilesJson = stringPreferencesKey("custom_rsvp_profiles_json") val hasSeenStartingTutorial = booleanPreferencesKey("has_seen_starting_tutorial") diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt index 470ec31c..bd2547b3 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PreferencesRepositoryImpl.kt @@ -18,6 +18,8 @@ import com.kairo.reader.core.model.RsvpProfileIds import com.kairo.reader.core.model.TimedReadingMode import com.kairo.reader.core.model.UserPreferences import com.kairo.reader.core.model.defaultConfig +import com.kairo.reader.core.model.profileCadenceIdentity +import com.kairo.reader.core.model.withReaderPreferencesFrom import com.kairo.reader.core.rsvp.RsvpSpeedControl import java.util.UUID import kotlinx.coroutines.flow.Flow @@ -126,7 +128,7 @@ class PreferencesRepositoryImpl(private val context: Context,) : PreferencesRepo if (migrationsComplete) return migrateLegacyBaseWpmIfNeeded(context.dataStore.data.first()) migrateRsvpSpeedCurveIfNeeded(context.dataStore.data.first()) - migrateRsvpPunctuationTuningIfNeeded(context.dataStore.data.first()) + context.dataStore.edit { migrateRsvpPresets(it, configCodec) } migrationsComplete = true } } @@ -172,46 +174,23 @@ class PreferencesRepositoryImpl(private val context: Context,) : PreferencesRepo } } - private suspend fun migrateRsvpPunctuationTuningIfNeeded(prefs: Preferences) { - val storedVersion = prefs[keys.rsvpPunctuationTuningVersion] ?: 1 - if (storedVersion >= CURRENT_RSVP_PUNCTUATION_TUNING_VERSION) return - - val selectedProfileId = - prefs[keys.rsvpProfile] - ?.let(::normalizeRsvpProfileId) - ?: RsvpProfileIds.builtIn(RsvpProfile.BALANCED) - val builtInProfile = RsvpProfileIds.parseBuiltIn(selectedProfileId) - - context.dataStore.edit { mutable -> - if (builtInProfile != null) { - val config = builtInProfile.defaultConfig() - configCodec.writePunctuationPauses(mutable, config) - configCodec.writePauseScaling(mutable, config) - mutable[keys.parentheticalMultiplier] = config.parentheticalMultiplier - mutable[keys.dialoguePunctuationScale] = config.dialoguePunctuationScale - mutable[keys.clausePauseFactor] = config.clausePauseFactor - mutable[keys.punctuationPause] = config.punctuationPauseFactor - mutable[keys.anticipatoryLandingBoost] = config.anticipatoryLandingBoost - mutable[keys.parentheticalAsideMultiplier] = config.parentheticalAsideMultiplier - } - mutable[keys.rsvpPunctuationTuningVersion] = - CURRENT_RSVP_PUNCTUATION_TUNING_VERSION - } - } - override suspend fun updateRsvpConfig(updater: (RsvpConfig) -> RsvpConfig) { + ensureMigrations() context.dataStore.edit { prefs -> val current = configCodec.readRsvpConfig(prefs) val timingInfo = configCodec.readTimingInfo(prefs, current) val updated = updater(current) .withTiming(timingInfo) - prefs[keys.rsvpProfile] = RsvpProfileIds.CUSTOM_UNSAVED + if (updated.profileCadenceIdentity() != current.profileCadenceIdentity()) { + prefs[keys.rsvpProfile] = RsvpProfileIds.CUSTOM_UNSAVED + } configCodec.writeRsvpConfig(prefs, updated, includeTiming = false) } } override suspend fun updateRsvpTempoMsPerWord(tempoMsPerWord: Long) { + ensureMigrations() context.dataStore.edit { prefs -> prefs[keys.tempoMsPerWord] = tempoMsPerWord.coerceAtLeast(RsvpSpeedControl.EXTREME_MIN_TEMPO_MS_PER_WORD) @@ -225,7 +204,9 @@ class PreferencesRepositoryImpl(private val context: Context,) : PreferencesRepo } override suspend fun selectRsvpProfile(profileId: String) { + ensureMigrations() context.dataStore.edit { prefs -> + val current = configCodec.readRsvpConfig(prefs) val normalized = normalizeRsvpProfileId(profileId) when { normalized == RsvpProfileIds.CUSTOM_UNSAVED -> { @@ -234,25 +215,21 @@ class PreferencesRepositoryImpl(private val context: Context,) : PreferencesRepo RsvpProfileIds.isBuiltIn(normalized) -> { val builtIn = RsvpProfileIds.parseBuiltIn(normalized) ?: RsvpProfile.BALANCED - val currentTiming = configCodec.readTimingInfo(prefs, builtIn.defaultConfig()) prefs[keys.rsvpProfile] = RsvpProfileIds.builtIn(builtIn) configCodec.writeRsvpConfig( prefs, - builtIn.defaultConfig().withTiming(currentTiming), - includeTiming = false, + builtIn.defaultConfig().withReaderPreferencesFrom(current), ) } RsvpProfileIds.isCustom(normalized) -> { - val currentTiming = configCodec.readTimingInfo(prefs, RsvpConfig()) val profiles = profileJsonCodec.parseCustomProfiles(prefs[keys.customRsvpProfilesJson]) val match = profiles.firstOrNull { it.id == normalized } prefs[keys.rsvpProfile] = normalized if (match != null) { configCodec.writeRsvpConfig( prefs, - match.config.withTiming(currentTiming), - includeTiming = false, + match.config.withReaderPreferencesFrom(current), ) } else { prefs[keys.rsvpProfile] = RsvpProfileIds.CUSTOM_UNSAVED @@ -272,6 +249,7 @@ class PreferencesRepositoryImpl(private val context: Context,) : PreferencesRepo ) { val trimmedName = name.trim().take(MAX_CUSTOM_PROFILE_NAME_LENGTH) if (trimmedName.isBlank()) return + ensureMigrations() context.dataStore.edit { prefs -> val existing = profileJsonCodec.parseCustomProfiles(prefs[keys.customRsvpProfilesJson]).toMutableList() @@ -451,5 +429,4 @@ class PreferencesRepositoryImpl(private val context: Context,) : PreferencesRepo private const val MAX_CUSTOM_PROFILE_NAME_LENGTH = 32 -private const val CURRENT_RSVP_PUNCTUATION_TUNING_VERSION = 2 private const val TAG = "PreferencesRepository" diff --git a/app/src/main/java/com/kairo/reader/data/preferences/RsvpPresetMigration.kt b/app/src/main/java/com/kairo/reader/data/preferences/RsvpPresetMigration.kt new file mode 100644 index 00000000..43c17ed0 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/data/preferences/RsvpPresetMigration.kt @@ -0,0 +1,25 @@ +package com.kairo.reader.data.preferences + +import androidx.datastore.preferences.core.MutablePreferences +import com.kairo.reader.core.model.RsvpProfile +import com.kairo.reader.core.model.RsvpProfileIds +import com.kairo.reader.core.model.defaultConfig +import com.kairo.reader.core.model.withReaderPreferencesFrom + +private const val CURRENT_RSVP_PRESET_VERSION = 2 + +/** Run inside the DataStore transaction so a stale selection cannot replace a custom cadence. */ +internal fun migrateRsvpPresets(prefs: MutablePreferences, codec: RsvpConfigPreferenceCodec) { + if ((prefs[PrefKeys.rsvpPresetVersion] ?: 0) >= CURRENT_RSVP_PRESET_VERSION) return + val selectedId = prefs[PrefKeys.rsvpProfile]?.let(::normalizeRsvpProfileId) + ?: RsvpProfileIds.builtIn(RsvpProfile.BALANCED) + val builtIn = RsvpProfileIds.parseBuiltIn(selectedId) + if (builtIn != null) { + val current = codec.readRsvpConfig(prefs) + codec.writeRsvpConfig( + prefs, + builtIn.defaultConfig().withReaderPreferencesFrom(current), + ) + } + prefs[PrefKeys.rsvpPresetVersion] = CURRENT_RSVP_PRESET_VERSION +} diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt index 8b079d81..dd9ce5fd 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt @@ -18,6 +18,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.kairo.reader.core.model.RsvpFrame import com.kairo.reader.core.rsvp.frameFloorMs +import com.kairo.reader.core.rsvp.scaledFrameDurationMs import com.kairo.reader.core.rsvp.shouldSkipBlinkFrame import com.kairo.reader.core.rsvp.timing.RsvpSessionTimingPolicy import kotlin.math.roundToLong @@ -259,12 +260,9 @@ internal fun RsvpPlaybackLoopEffect( runtime.rampStartFrameIndex, runtime.resumePreparationScale, ) - val frameMs = - (frame.durationMs * rampMultiplier) - .roundToLong() - .coerceAtLeast(MIN_FRAME_DELAY_MS) + val frameMs = scaledFrameDurationMs(frame, config, effectiveTempoMs, tempoScale) var scaledMs = - ((frameMs + resumeDelayMs) * tempoScale * runtime.comprehensionPaceScale) + ((frameMs * rampMultiplier + resumeDelayMs * tempoScale) * runtime.comprehensionPaceScale) .roundToLong() .coerceAtLeast(MIN_FRAME_DELAY_MS) val floorMs = frameFloorMs(frame, config, effectiveTempoMs) diff --git a/app/src/main/java/com/kairo/reader/ui/settings/RsvpProfileSelector.kt b/app/src/main/java/com/kairo/reader/ui/settings/RsvpProfileSelector.kt index e53d716a..a30a4797 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/RsvpProfileSelector.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/RsvpProfileSelector.kt @@ -34,6 +34,7 @@ import com.kairo.reader.core.model.RsvpCustomProfile import com.kairo.reader.core.model.RsvpProfile import com.kairo.reader.core.model.RsvpProfileIds import com.kairo.reader.core.model.defaultConfig +import com.kairo.reader.core.model.profileCadenceIdentity import com.kairo.reader.core.rsvp.MILLISECONDS_PER_MINUTE import com.kairo.reader.core.rsvp.RsvpSpeedControl.EXTREME_MIN_TEMPO_MS_PER_WORD import kotlin.math.roundToInt @@ -326,13 +327,7 @@ internal fun RsvpConfig.withLiveTempo(tempoMsPerWord: Long): RsvpConfig { ) } -internal fun RsvpConfig.asProfileIdentityConfig(): RsvpConfig { - val defaults = RsvpConfig() - return copy( - tempoMsPerWord = defaults.tempoMsPerWord, - baseWpm = defaults.baseWpm, - ) -} +internal fun RsvpConfig.asProfileIdentityConfig(): RsvpConfig = profileCadenceIdentity() internal fun coercePhraseChunkWordLimit(value: Int): Int = value.coerceIn(PHRASE_CHUNK_MIN_WORDS, PHRASE_CHUNK_MAX_WORDS) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d440ef2f..900747ea 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -206,17 +206,17 @@ Aktuelles speichern Profil speichern Profilname - Speichert die aktuellen Schnelllesen-Einstellungen als benanntes Profil. + Speichert diesen Leserhythmus. Tempo und visuelle Hilfen bleiben unabhängig vom Profil. Profil löschen? Dies kann nicht rückgängig gemacht werden. Ausgewogen - Weicher, gut lesbarer Standard + Natürlicher Rhythmus mit kurzen Phrasen und klaren Pausen Entspannt - Mehr Luft und stärkere Pausen - Sprint - Schnell, leichte Pausen, mehr Flow + Entspannte Phrasen mit mehr Raum zwischen Gedanken + Überfliegen + Zügiger Überblick mit kürzeren Pausen und weniger Betonung Lernen - Bedachtes Tempo für Verständnis + Ganze Begriffe und mehr Zeit für schwierige Wörter und Gedanken Geschätztes Tempo: %1$d Wörter/min Tempo wird geschätzt… @@ -334,11 +334,11 @@ Begrenzt Phrasen-Chunks auf 2 oder 3 Wörter, wenn der Text natürlich gelesen wird. %1$d Wörter Erzählung - Erzählfluss mit innerer Stimme und Ausdruck + Ausdrucksvolles Erzählen mit sanfter Betonung und Dialogen Fokus - Schärferes, effizientes Tempo ohne Interpunktionshinweise zu verlieren + Ein Wort nach dem anderen in einem ruhigen, gleichmäßigen Rhythmus Flow - Kontinuierlicher mittelschneller Rhythmus mit weichen Übergängen + Zusammenhängende Phrasen mit bis zu drei Wörtern und sanften Übergängen Einführung Die geführte App-Einführung erneut abspielen. Info diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2e227e04..8d61c419 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -206,17 +206,17 @@ Guardar actual Guardar perfil Nombre del perfil - Guarda la configuración de lectura rápida actual como un perfil con nombre. + Guarda este ritmo de lectura. La velocidad y las ayudas visuales son independientes del perfil. ¿Eliminar perfil? Esto no se puede deshacer. Equilibrado - Suave y legible por defecto + Ritmo natural con frases cortas y pausas claras Relajado - Más respiro y pausas más fuertes - Sprint - Rápido, pausas ligeras, más fluidez + Frases relajadas con más espacio entre ideas + Vistazo + Repaso ágil con pausas más cortas y menos énfasis Estudio - Ritmo deliberado para comprensión + Términos completos y más tiempo para palabras e ideas difíciles Ritmo estimado: %1$d ppm Estimando ritmo… @@ -334,11 +334,11 @@ Limita los bloques de frase a 2 o 3 palabras cuando el texto se lee de forma natural. %1$d palabras Narrativo - Narración con voz interior y flujo expresivo + Narración expresiva con énfasis suave y diálogo Enfoque - Ritmo más nítido y eficiente sin perder señales de puntuación + Una palabra a la vez con un ritmo estable y moderado Flujo - Ritmo continuo medio-rápido con transiciones suaves + Grupos de hasta tres palabras con transiciones suaves Tutorial inicial Repetir la guía de la app. Información diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f573b026..f5ad3c43 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -206,17 +206,17 @@ Enregistrer l\'actuel Enregistrer le profil Nom du profil - Enregistre les réglages actuels de lecture rapide comme profil nommé. + Enregistre ce rythme de lecture. La vitesse et les aides visuelles restent indépendantes du profil. Supprimer le profil ? Impossible d\'annuler. Équilibré - Par défaut, fluide et lisible + Rythme naturel avec des groupes courts et des pauses claires Détendu - Plus de respiration et des pauses plus fortes - Sprint - Rapide, pauses légères, plus de flux + Lecture détendue avec plus de temps entre les idées + Survol + Relecture rapide avec des pauses plus courtes et moins d’accentuation Étude - Rythme délibéré pour la compréhension + Termes entiers et plus de temps pour les mots et les idées difficiles Rythme estimé : %1$d mots/min Estimation du rythme… @@ -334,11 +334,11 @@ Limite les groupes de phrase à 2 ou 3 mots lorsque le texte se lit naturellement. %1$d mots Narratif - Récit à voix intérieure avec flux expressif + Récit expressif avec une accentuation douce et des dialogues Focus - Rythme plus net et efficace sans perdre les indices de ponctuation + Un mot à la fois avec un rythme régulier et sobre Flux - Rythme continu mi-rapide avec transitions fluides + Groupes cohérents de trois mots maximum avec des transitions légères Tutoriel de démarrage Rejouer la visite guidée de l’app. Infos diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9e3e8a36..ce537658 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -206,17 +206,17 @@ 現在を保存 プロファイルを保存 プロファイル名 - 現在の速読設定を名前付きプロファイルとして保存します。 + この読書リズムを保存します。速度と視覚補助はプロファイルとは別に保持されます。 プロファイルを削除しますか? 元に戻せません。 バランス - 滑らかで読みやすい標準 + 短いフレーズと明確な間で自然に読む チル - 余裕のある間と強めのポーズ - スプリント - 高速、軽いポーズ、高い流れ + 考えの区切りに余裕を持たせた、ゆったりした読み方 + 流し読み + 間と強調を抑えて、すばやく読み返す 学習 - 理解重視のゆったりしたペース + 用語をまとめて表示し、難しい語や考えに時間をかける 推定ペース: %1$d 語/分 ペースを推定中… @@ -334,11 +334,11 @@ 自然に読める場合、フレーズチャンクを2語または3語に制限します。 %1$d語 ナラティブ - 内声の物語調で表情のある流れ + 穏やかな強調と会話の抑揚で物語を読む フォーカス - 句読点の手がかりを保ったまま、より鋭く効率的なペース + 控えめで安定したリズムで、一度に1語ずつ読む フロー - 滑らかな移行を持つ連続した中高速リズム + 最大3語のまとまりを滑らかにつなぐ 開始チュートリアル アプリのガイドをもう一度再生します。 情報 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9043f5e8..0a066647 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -206,17 +206,17 @@ Salvar atual Salvar perfil Nome do perfil - Salva as configurações atuais de leitura rápida como um perfil com nome. + Salva este ritmo de leitura. A velocidade e os recursos visuais são independentes do perfil. Excluir perfil? Isso não pode ser desfeito. Equilibrado - Padrão suave e legível + Ritmo natural com frases curtas e pausas claras Tranquilo - Mais respiro e pausas mais fortes - Sprint - Rápido, pausas leves, maior fluxo + Frases tranquilas com mais espaço entre as ideias + Leitura rápida + Revisão ágil com pausas mais curtas e menos ênfase Estudo - Ritmo deliberado para compreensão + Termos inteiros e mais tempo para palavras e ideias difíceis Ritmo estimado: %1$d ppm Estimando ritmo… @@ -334,11 +334,11 @@ Limita blocos de frase a 2 ou 3 palavras quando o texto soa natural. %1$d palavras Narrativo - Narração com voz interior e fluxo expressivo + Narração expressiva com ênfase suave e diálogo Foco - Ritmo mais nítido e eficiente sem perder pistas de pontuação + Uma palavra por vez com ritmo estável e moderado Fluxo - Ritmo contínuo médio-rápido com transições suaves + Grupos de até três palavras com transições suaves Tutorial inicial Reproduzir novamente o guia do app. Informações diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e92dc436..6e1114ef 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -206,17 +206,17 @@ 保存当前 保存配置 配置名称 - 将当前速读设置保存为命名配置。 + 保存此阅读节奏。速度和视觉辅助设置独立于配置。 删除配置? 无法撤销。 均衡 - 默认流畅且易读 + 短语配合清晰停顿,节奏自然 放松 - 更多呼吸空间和更强停顿 - 冲刺 - 快速、轻停顿、高流畅度 + 短语节奏舒缓,在思路之间留出更多时间 + 略读 + 缩短停顿、减少强调,快速回顾 学习 - 为了理解而刻意放慢 + 完整呈现术语,为难词和复杂思路留出更多时间 预计速度:%1$d 词/分 正在估算速度… @@ -334,11 +334,11 @@ 当文本自然时,将短语分块限制为 2 或 3 个词。 %1$d 个词 叙事 - 带有内心声音和表现力流动的叙事 + 通过轻柔强调和对话节奏,生动呈现故事 专注 - 更清晰高效的节奏,同时保留标点提示 + 每次一词,节奏平稳、变化较少 流动 - 连续的中高速节奏,过渡顺滑 + 每组最多三个词,过渡轻快连贯 入门教程 重新播放应用引导。 信息 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f07929a5..5622cc27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -331,23 +331,23 @@ Save current Save profile Profile name - Saves the current RSVP settings as a named profile. + Saves this reading rhythm. Speed and visual aids stay independent of profiles. Delete profile? This cannot be undone. Balanced - Natural all-round cadence with clear punctuation breathing + Natural rhythm with short phrases and clear pauses Chill - Slow, spacious pacing for relaxed reading + Relaxed phrases with more space between thoughts Narrative - Inner-voice storytelling with expressive flow + Expressive storytelling with gentle emphasis and dialogue Focus - Sharper, efficient pacing without dropping punctuation cues + One word at a time with a steady, restrained rhythm Flow - Continuous mid-fast rhythm with smooth transitions - Sprint - Very fast, but keeps punctuation pauses readable + Connected phrases of up to three words with light transitions + Skim + Brisk review with shorter pauses and less emphasis Study - Deliberate comprehension-focused timing for dense text + Whole terms and extra time for difficult words and ideas Estimated pace: %1$d WPM Estimating pace… 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 638ee487..ab88080d 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 @@ -26,7 +26,7 @@ class RsvpProfileDefaultsTest { @Test fun comprehensionProfilesUseConservativePhraseChunking() { - val chunkedProfiles = setOf(RsvpProfile.NARRATIVE, RsvpProfile.FLOW, RsvpProfile.STUDY) + val chunkedProfiles = setOf(RsvpProfile.BALANCED, RsvpProfile.CHILL, RsvpProfile.NARRATIVE, RsvpProfile.FLOW, RsvpProfile.SPRINT) RsvpProfile.entries.forEach { profile -> val config = profile.defaultConfig() assertEquals( @@ -69,7 +69,7 @@ class RsvpProfileDefaultsTest { assertTrue("Expected full stops to settle", config.periodPauseMs >= 330L) assertTrue("Expected expressive sentence marks to settle", config.sentenceEndPauseMs >= 350L) assertTrue("Expected paragraphs to create a real reset", config.paragraphPauseMs >= 430L) - assertTrue("Expected global punctuation breathing above neutral", config.punctuationPauseFactor >= 1.08) + assertTrue("Explicit punctuation should not receive a second global boost", config.punctuationPauseFactor == 1.0) assertTrue("Expected dialogue punctuation to stay readable", config.dialoguePunctuationScale >= 0.94) assertFalse("Expected first-run parentheticals to stay readable", config.useParentheticalAside) } @@ -78,7 +78,7 @@ class RsvpProfileDefaultsTest { fun builtInProfilesLeanIntoPunctuationWithoutLosingTheirSpeedShape() { RsvpProfile.entries.forEach { profile -> val config = profile.defaultConfig() - assertTrue("Expected readable comma breath for ${profile.name}", config.commaPauseMs >= 120L) + assertTrue("Expected readable comma breath for ${profile.name}", config.commaPauseMs >= 110L) assertTrue( "Expected semicolon to be closer to a stop than a comma for ${profile.name}", config.semicolonPauseMs >= (config.commaPauseMs * 1.45).toLong(), @@ -88,8 +88,8 @@ class RsvpProfileDefaultsTest { config.minPauseScale >= 0.82, ) assertTrue( - "Expected punctuation breathing to be above neutral for ${profile.name}", - config.punctuationPauseFactor >= 1.04, + "Expected explicit punctuation timing for ${profile.name}", + config.punctuationPauseFactor == 1.0, ) assertTrue( "Expected dialogue punctuation not to collapse for ${profile.name}", @@ -147,72 +147,55 @@ class RsvpProfileDefaultsTest { } @Test - fun builtInProfilesTuneNaturalReadingControls() { - RsvpProfile.entries.forEach { profile -> - val config = profile.defaultConfig() - assertTrue("Expected focal stress on for ${profile.name}", config.useFocalStress) - assertTrue( - "Expected supporting words to compress gently for ${profile.name}", - config.focalSupportCompression in 0.86..0.98, - ) - assertTrue( - "Expected anticipatory landing on for ${profile.name}", - config.useAnticipatoryLanding, - ) - assertTrue( - "Expected landing boost to stay subtle for ${profile.name}", - config.anticipatoryLandingBoost in 1.03..1.12, - ) - assertTrue( - "Expected dialogue punctuation to stay readable for ${profile.name}", - config.dialoguePunctuationScale in 0.90..1.0, - ) - assertTrue( - "Expected paragraph strength to hold natural breaks for ${profile.name}", - config.paragraphPauseMultiplier in 1.10..1.75, - ) - assertTrue( - "Expected page breaks to remain stronger than paragraph breaks for ${profile.name}", - config.pageBreakPauseMultiplier > config.paragraphPauseMultiplier + 1.5, - ) - assertTrue( - "Expected ORP highlight on by default for ${profile.name}", - config.orpHighlightEnabled, - ) - assertTrue( - "Expected punctuation breathing to be intentionally profiled for ${profile.name}", - config.punctuationPauseFactor in 1.0..1.25, - ) - assertTrue( - "Expected phrase chunks to stay short for ${profile.name}", - config.maxWordsPerUnit == 2, - ) - assertTrue( - "Expected opt-in phrase chunk character budget to stay compact for ${profile.name}", - config.maxCharsPerUnit in 12..15, - ) - } + fun profilesHaveDistinctReadingRoles() { + val balanced = RsvpProfile.BALANCED.defaultConfig() + val narrative = RsvpProfile.NARRATIVE.defaultConfig() + val focus = RsvpProfile.FOCUS.defaultConfig() + val flow = RsvpProfile.FLOW.defaultConfig() + val skim = RsvpProfile.SPRINT.defaultConfig() + val study = RsvpProfile.STUDY.defaultConfig() + val chill = RsvpProfile.CHILL.defaultConfig() + + assertTrue(narrative.prosodyStrength > balanced.prosodyStrength) + assertTrue(chill.paragraphPauseMs > narrative.paragraphPauseMs) + assertTrue(chill.smoothingAlpha < balanced.smoothingAlpha) + assertFalse(focus.enablePhraseChunking) + assertFalse(focus.useProsodyPacing) + assertFalse(focus.useFocalStress) + assertFalse(focus.useAnticipatoryLanding) + assertTrue(focus.maxSlowdownFactor < balanced.maxSlowdownFactor) + assertEquals(3, flow.maxWordsPerUnit) + assertTrue(flow.maxCharsPerUnit > narrative.maxCharsPerUnit) + assertTrue(skim.enablePhraseChunking) + assertFalse(skim.useProsodyPacing) + assertTrue(skim.complexWordHoldMs < focus.complexWordHoldMs) + assertFalse(study.enablePhraseChunking) + assertFalse(study.useFocalStress) + assertTrue(study.longWordMinMs > balanced.longWordMinMs) + assertTrue(study.maxChunkLength >= 32) + assertTrue(study.adaptiveDifficultyMaxHoldMs > balanced.adaptiveDifficultyMaxHoldMs) + assertEquals(7, RsvpProfile.entries.map { it.defaultConfig().profileCadenceIdentity() }.toSet().size) + } - val naturalProfiles = - RsvpProfile.entries - .map { profile -> - val config = profile.defaultConfig() - listOf( - config.punctuationPauseFactor, - config.focalSupportCompression, - config.anticipatoryLandingBoost, - config.dialoguePunctuationScale, - config.parentheticalAsideMultiplier, - config.paragraphPauseMultiplier, - config.pageBreakPauseMultiplier, - ) - }.toSet() - - assertEquals( - "Expected every built-in profile to carry its own natural-reading shape", - RsvpProfile.entries.size, - naturalProfiles.size, + @Test + fun selectingCadencePreservesSpeedAndVisualPreferences() { + val current = RsvpConfig().copy( + tempoMsPerWord = 230L, + baseWpm = 260, + contextAssistMode = RsvpContextAssistMode.SENTENCE_TICKER, + blinkMode = BlinkMode.ADAPTIVE, + orpEnabled = false, + orpHighlightEnabled = false, + orpGuideEnabled = true, + orpGuideBrightness = 0.65, + orpGuideThickness = 2.0, ) + RsvpProfile.entries.forEach { profile -> + val preset = profile.defaultConfig() + val selected = preset.withReaderPreferencesFrom(current) + assertEquals(preset.profileCadenceIdentity(), selected.profileCadenceIdentity()) + assertEquals(current, current.profileCadenceIdentity().withReaderPreferencesFrom(selected)) + } } @Test diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpNaturalPunctuationTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpNaturalPunctuationTest.kt new file mode 100644 index 00000000..afb92856 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpNaturalPunctuationTest.kt @@ -0,0 +1,123 @@ +package com.kairo.reader.core.rsvp + +import com.kairo.reader.core.model.BlinkMode +import com.kairo.reader.core.model.Chapter +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.RsvpFrame +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.timing.sentenceWrapUpFactor +import com.kairo.reader.core.tokenization.Tokenizer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpNaturalPunctuationTest { + private val engine = ComprehensionRsvpEngine() + private val english = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + + @Test + fun fullStopHasTheSameHoldBeforeUppercaseAndLowercaseRestarts() { + for (tempo in listOf(55L, 150L, 250L)) { + for (profile in RsvpProfile.entries) { + val config = profile.defaultConfig().copy(tempoMsPerWord = tempo) + val upper = homeFrame("We went home. Then we rested.", config) + val lower = homeFrame("We went home. then we rested.", config) + assertEquals("$profile at $tempo ms", upper.punctuationHoldMs, lower.punctuationHoldMs) + assertEquals("Capitalisation alone must not rush the ending", upper.durationMs, lower.durationMs) + } + } + } + + @Test + fun everyPresetKeepsAConsistentPunctuationHierarchyAtMultipleSpeeds() { + for (tempo in listOf(55L, 150L, 250L)) { + for (profile in RsvpProfile.entries) { + val config = profile.defaultConfig().copy(tempoMsPerWord = tempo) + fun hold(mark: String) = requireNotNull(homeFrame("We went home$mark Then we rested.", config).punctuationHoldMs) + val comma = hold(",") + val semicolon = hold(";") + val colon = hold(":") + val period = hold(".") + assertTrue("$profile comma must breathe", comma >= config.commaPauseMs * config.minPauseScale) + assertTrue("$profile semicolon must exceed comma", semicolon > comma) + assertTrue("$profile colon must exceed comma", colon > comma) + assertTrue("$profile full stop must exceed clause breaks", period > maxOf(semicolon, colon)) + assertTrue("$profile sentence ending must settle", hold("!") >= period) + } + } + } + + @Test + fun shortSentencesKeepTheirStopAndLongSentencesEarnMoreTime() { + assertEquals(1.0, sentenceWrapUpFactor(1), 0.0) + assertEquals(1.0, sentenceWrapUpFactor(6), 0.0) + assertTrue(sentenceWrapUpFactor(22) > sentenceWrapUpFactor(6)) + } + + @Test + fun liveSpeedChangesScaleReadingTimeSeparatelyFromPunctuation() { + val config = RsvpProfile.BALANCED.defaultConfig() + val frame = RsvpFrame( + tokens = listOf(Token("home", TokenType.WORD), Token(".", TokenType.PUNCTUATION)), + durationMs = 700L, + punctuationHoldMs = 400L, + ) + assertEquals(550L, scaledFrameDurationMs(frame, config, 75L, 0.5)) + assertEquals(700L, scaledFrameDurationMs(frame, config, 150L, 1.0)) + assertEquals(1000L, scaledFrameDurationMs(frame, config, 300L, 2.0)) + assertEquals( + (60_000.0 / 550).toInt(), + RsvpEstimatedReadingPace.estimateChapterPreviewWpm(config, listOf(frame), 150L, 75L), + ) + } + + @Test + fun punctuationHoldBelongsToTheLastVisiblePartOfASplitWord() { + val config = RsvpProfile.FOCUS.defaultConfig().copy(maxChunkLength = 4, blinkMode = BlinkMode.ADAPTIVE) + val frames = frames("characteristically. Next we rested.", config) + val parts = frames.filter { it.originalTokenIndex == 0 && !it.isWordSeparation } + assertTrue(parts.size > 1) + assertTrue(parts.dropLast(1).all { it.punctuationHoldMs == 0L }) + assertTrue(parts.last().tokens.any { it.text == "." }) + assertTrue(requireNotNull(parts.last().punctuationHoldMs) > 0L) + assertTrue(frames.filter { it.isWordSeparation }.all { it.punctuationHoldMs == 0L }) + assertTrue(frames.all { it.durationMs >= (it.punctuationHoldMs ?: 0L) }) + } + + @Test + fun numericSeparatorsAndTitleAbbreviationsDoNotBecomeFullStops() { + val config = RsvpProfile.BALANCED.defaultConfig() + for (text in listOf("Dr. Alice arrived.", "3.14 is pi.", "1,000 items arrived.")) { + val first = frames(text, config).first { it.tokens.any { token -> token.type == TokenType.WORD } } + assertEquals("Numeric and title punctuation should flow: $text", 0L, first.punctuationHoldMs) + val withoutPunctuation = first.copy(tokens = first.tokens.filter { it.type == TokenType.WORD }) + assertEquals( + "Playback must use the same abbreviation decision as generation: $text", + frameFloorMs(withoutPunctuation, config, 80L), + frameFloorMs(first, config, 80L), + ) + } + } + + @Test + fun shortEmphaticAnswersAreNotMistakenForAbbreviations() { + val config = RsvpProfile.BALANCED.defaultConfig() + for (text in listOf("YES. Then we rested.", "NO. Then we rested.", "No. then we rested.")) { + val first = frames(text, config).first() + assertTrue("An answer must retain its stop: $text", requireNotNull(first.punctuationHoldMs) >= config.periodPauseMs) + } + assertEquals(0L, frames("No. 7 arrived.", config).first().punctuationHoldMs) + assertEquals(0L, frames("J. Smith arrived.", config).first().punctuationHoldMs) + } + + private fun homeFrame(text: String, config: RsvpConfig) = + frames(text, config).first { frame -> frame.tokens.any { it.text == "home" } } + + private fun frames(text: String, config: RsvpConfig): List { + val tokens = Tokenizer().tokenize(Chapter(0, "Punctuation fixture", "", text)) + return engine.generateFrames(tokens, 0, config, english) + } +} 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 new file mode 100644 index 00000000..b19b18ad --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPresetCadenceTest.kt @@ -0,0 +1,72 @@ +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.RsvpProfile +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.model.defaultConfig +import com.kairo.reader.core.tokenization.Tokenizer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpPresetCadenceTest { + private val engine = ComprehensionRsvpEngine() + private val options = RsvpGenerationOptions(languagePolicy = RsvpLanguagePolicy.ENGLISH) + private val tokens = Tokenizer().tokenize( + Chapter( + index = 0, + title = "Cadence fixture", + htmlContent = "", + plainText = "Mara put down her bag and listened. She had remembered the date, the place, and the promise; " + + "she had simply needed more time. The measurements contain uncertainty. " + + "Repeated observations distinguish a consistent effect from random variation. " + + "Do not send another payment until the first one has been confirmed.", + ) + ) + + @Test + fun allProfilesKeepSourceWordsAndShowWholeOrdinaryTermsAtMultipleSpeeds() { + 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 }, + ) + assertTrue(frames.all { it.durationMs > 0 }) + } + } + } + + @Test + fun flowGroupsWordsWhileFocusAndStudyShowOneAtATime() { + val flow = frames(RsvpProfile.FLOW.defaultConfig()) + assertTrue(flow.any { frame -> frame.tokens.count { it.type == TokenType.WORD } == 3 }) + for (profile in listOf(RsvpProfile.FOCUS, RsvpProfile.STUDY)) { + assertTrue(frames(profile.defaultConfig()).all { frame -> frame.tokens.count { it.type == TokenType.WORD } <= 1 }) + } + } + + @Test + fun skimIsLighterAndStudyMoreDeliberateAtTheSameTempo() { + val focus = frames(RsvpProfile.FOCUS.defaultConfig()).sumOf { it.durationMs } + val skim = frames(RsvpProfile.SPRINT.defaultConfig()).sumOf { it.durationMs } + val study = frames(RsvpProfile.STUDY.defaultConfig()).sumOf { it.durationMs } + assertTrue("Skim should spend less time than Focus on this review passage", skim < focus) + assertTrue("Study should allow more processing time", study > focus) + } + + @Test + fun naturalExpressionStillChangesCadenceAtComfortableTempo() { + val narrative = RsvpProfile.NARRATIVE.defaultConfig().copy(tempoMsPerWord = 250L, enablePhraseChunking = false) + val expressive = engine.generateFrames(tokens, 0, narrative, options).map { it.durationMs } + val restrained = engine.generateFrames(tokens, 0, narrative.copy(useProsodyPacing = false), options).map { it.durationMs } + assertNotEquals(expressive, restrained) + val noFocalStress = engine.generateFrames(tokens, 0, narrative.copy(useFocalStress = false), options).map { it.durationMs } + assertNotEquals(expressive, noFocalStress) + } + + private fun frames(config: RsvpConfig) = engine.generateFrames(tokens, 0, config.copy(tempoMsPerWord = 150L), options) +} diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPunctuationTimingPolicyTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPunctuationTimingPolicyTest.kt index 6805746d..538cc704 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPunctuationTimingPolicyTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPunctuationTimingPolicyTest.kt @@ -98,7 +98,7 @@ class RsvpPunctuationTimingPolicyTest { } @Test - fun punctuationTimingNarrowsAtHigherSpeed() { + fun punctuationBudgetAndFloorAreIndependentOfWordSpeed() { val slow = RsvpPunctuationTimingPolicy.resolvePauseTiming( token = punctuation(","), @@ -122,8 +122,8 @@ class RsvpPunctuationTimingPolicyTest { ), ) - assertTrue(fast.baseMs < slow.baseMs) - assertTrue(fast.floorMs < slow.floorMs) + assertEquals(slow.baseMs, fast.baseMs, 0.0) + assertEquals(slow.floorMs, fast.floorMs, 0.0) } @Test 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 new file mode 100644 index 00000000..b3ab3f63 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpBoundaryIntegrationTest.kt @@ -0,0 +1,44 @@ +package com.kairo.reader.core.rsvp.timing + +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.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 +import org.junit.Test + +class RsvpBoundaryIntegrationTest { + @Test + fun integrationAndLandingShareOneBreathWhileExplicitPunctuationStillAddsTime() { + val config = RsvpConfig(tempoMsPerWord = 150L, useAdaptiveTiming = false) + val baseline = duration(config.copy(usePunctuationLandingHold = false), 0.0) + val landing = duration(config, 0.0) + val integration = duration(config.copy(usePunctuationLandingHold = false), 100.0) + val both = duration(config, 100.0) + assertTrue(landing > baseline) + assertEquals(baseline + 100, integration) + assertEquals(maxOf(landing, integration), both) + assertTrue(duration(config.copy(periodPauseMs = config.periodPauseMs + 200), 100.0) > both) + } + + private fun duration(config: RsvpConfig, integration: Double): Long = computeUnitDurationMs( + RsvpUnitTimingInput( + frameTokens = listOf(Token("finished", TokenType.WORD), Token(".", TokenType.PUNCTUATION)), + 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), + nextWord = Token("Next", TokenType.WORD), + boundaryBefore = BoundaryBefore.NONE, + thoughtCues = listOf(RsvpThoughtCue(0, 2, true, false, integration)), + ), + ) +} diff --git a/app/src/test/java/com/kairo/reader/data/preferences/RsvpPresetMigrationTest.kt b/app/src/test/java/com/kairo/reader/data/preferences/RsvpPresetMigrationTest.kt new file mode 100644 index 00000000..78586fa0 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/data/preferences/RsvpPresetMigrationTest.kt @@ -0,0 +1,65 @@ +package com.kairo.reader.data.preferences + +import androidx.datastore.preferences.core.mutablePreferencesOf +import com.kairo.reader.core.model.BlinkMode +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.RsvpContextAssistMode +import com.kairo.reader.core.model.RsvpProfile +import com.kairo.reader.core.model.RsvpProfileIds +import com.kairo.reader.core.model.defaultConfig +import com.kairo.reader.core.model.withReaderPreferencesFrom +import org.junit.Assert.assertEquals +import org.junit.Test + +class RsvpPresetMigrationTest { + private val codec = RsvpConfigPreferenceCodec(PrefKeys, RsvpProfileJsonCodec()) + + @Test + fun upgradesEveryBuiltInWhileKeepingReaderPreferences() { + RsvpProfile.entries.forEach { profile -> + val prefs = mutablePreferencesOf(PrefKeys.rsvpProfile to RsvpProfileIds.builtIn(profile)) + codec.writeRsvpConfig( + prefs, + RsvpConfig().copy( + tempoMsPerWord = 230L, + commaPauseMs = 999L, + contextAssistMode = RsvpContextAssistMode.FULL_CLAUSE, + blinkMode = BlinkMode.ADAPTIVE, + orpEnabled = false, + orpHighlightEnabled = false, + orpGuideEnabled = true, + orpGuideBrightness = 0.65, + orpGuideThickness = 2.0, + ) + ) + val before = codec.readRsvpConfig(prefs) + migrateRsvpPresets(prefs, codec) + assertEquals(profile.defaultConfig().withReaderPreferencesFrom(before), codec.readRsvpConfig(prefs)) + assertEquals(RsvpProfileIds.builtIn(profile), prefs[PrefKeys.rsvpProfile]) + } + } + + @Test + fun savedAndUnsavedCustomCadencesAreUntouched() { + listOf("user:mine", RsvpProfileIds.CUSTOM_UNSAVED).forEach { id -> + val prefs = mutablePreferencesOf(PrefKeys.rsvpProfile to id) + codec.writeRsvpConfig(prefs, RsvpConfig().copy(commaPauseMs = 999L)) + prefs[PrefKeys.customRsvpProfilesJson] = "saved profiles must remain byte for byte" + val before = prefs.asMap() + migrateRsvpPresets(prefs, codec) + assertEquals(before, prefs.asMap().filterKeys { it != PrefKeys.rsvpPresetVersion }) + } + } + + @Test + fun migrationRunsOnlyOnceAndFreshInstallGetsBalanced() { + val prefs = mutablePreferencesOf() + migrateRsvpPresets(prefs, codec) + val actual = codec.readRsvpConfig(prefs) + assertEquals(RsvpProfile.BALANCED.defaultConfig().withReaderPreferencesFrom(actual), actual) + prefs[PrefKeys.commaPauseMs] = 777L + val before = prefs.asMap() + migrateRsvpPresets(prefs, codec) + assertEquals(before, prefs.asMap()) + } +} diff --git a/app/src/test/java/com/kairo/reader/ui/settings/RsvpProfileSelectionStateTest.kt b/app/src/test/java/com/kairo/reader/ui/settings/RsvpProfileSelectionStateTest.kt index 623137a3..743e02c5 100644 --- a/app/src/test/java/com/kairo/reader/ui/settings/RsvpProfileSelectionStateTest.kt +++ b/app/src/test/java/com/kairo/reader/ui/settings/RsvpProfileSelectionStateTest.kt @@ -1,6 +1,8 @@ package com.kairo.reader.ui.settings +import com.kairo.reader.core.model.BlinkMode import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.RsvpContextAssistMode import com.kairo.reader.core.model.RsvpCustomProfile import com.kairo.reader.core.model.RsvpProfile import com.kairo.reader.core.model.RsvpProfileIds @@ -56,6 +58,9 @@ class RsvpProfileSelectionStateTest { RsvpProfile.NARRATIVE.defaultConfig().copy( tempoMsPerWord = 88L, baseWpm = 681, + contextAssistMode = RsvpContextAssistMode.SENTENCE_TICKER, + blinkMode = BlinkMode.ADAPTIVE, + orpGuideEnabled = true, ), )