diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt index a330bc2..88017a7 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/ReadAloudPlatform.android.kt @@ -80,6 +80,8 @@ private object AndroidReadAloudEngine { private const val SelectedEngineKey = "selected_engine" private const val EngineChoiceMadeKey = "engine_choice_made" private const val SelectedVoiceKey = "selected_voice" + private const val TextReplacementsKey = "text_replacements" + private const val EmptyTextReplacementsJson = "[]" private val RussianLocale = Locale.forLanguageTag("ru-RU") private val mutableState = MutableStateFlow(ReadAloudState()) @@ -153,7 +155,12 @@ private object AndroidReadAloudEngine { fun refreshSettings(context: Context) { val appContext = context.applicationContext - mutableSettingsState.value = mutableSettingsState.value.copy(loading = true, message = null) + val textReplacements = userTextReplacements(appContext) + mutableSettingsState.value = mutableSettingsState.value.copy( + textReplacements = textReplacements, + loading = true, + message = null, + ) engineProbe?.shutdown() var probe: TextToSpeech? = null probe = TextToSpeech(appContext) { status -> @@ -182,6 +189,7 @@ private object AndroidReadAloudEngine { mutableSettingsState.value = mutableSettingsState.value.copy( engines = engines, selectedEngineId = selectedEngine, + textReplacements = textReplacements, ) probe?.shutdown() if (engineProbe === probe) engineProbe = null @@ -419,6 +427,14 @@ private object AndroidReadAloudEngine { private fun selectedVoiceId(context: Context): String? = context.readAloudPrefs().getString(SelectedVoiceKey, null) + private fun userTextReplacements(context: Context): List { + val prefs = context.readAloudPrefs() + if (!prefs.contains(TextReplacementsKey)) { + prefs.edit().putString(TextReplacementsKey, EmptyTextReplacementsJson).apply() + } + return emptyList() + } + private fun Context.readAloudPrefs() = getSharedPreferences(SettingsPrefs, Context.MODE_PRIVATE) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt index 69dafc0..16026a0 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReadAloudPlatform.kt @@ -31,11 +31,17 @@ data class ReadAloudVoiceOption( val networkRequired: Boolean, ) +data class ReadAloudTextReplacement( + val from: String, + val to: String, +) + data class ReadAloudSettingsState( val engines: List = emptyList(), val selectedEngineId: String? = null, val voices: List = emptyList(), val selectedVoiceId: String? = null, + val textReplacements: List = emptyList(), val loading: Boolean = false, val message: String? = null, ) diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index a24a311..b0c8cbf 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -799,10 +799,37 @@ private fun String.isReadAloudPauseBreak(): Boolean { } private fun String.toReadAloudSpokenText(): String = - replace(Regex("\\.{2,}"), ".") + applyReadAloudTextReplacements(ReadAloudHardcodedTextReplacements) + .withReadAloudStressMarkers() + .replace(Regex("\\.{2,}"), ".") + .replace(Regex("([!?])\\.+"), "$1") .replace('…', '.') .trim() +private fun String.applyReadAloudTextReplacements(replacements: List): String = + replacements.fold(this) { text, replacement -> + text.replace(replacement.from, replacement.to) + } + +private fun String.withReadAloudStressMarkers(): String = buildString(length) { + var index = 0 + while (index < this@withReadAloudStressMarkers.length) { + val current = this@withReadAloudStressMarkers[index] + val next = this@withReadAloudStressMarkers.getOrNull(index + 1) + if ((current == '/' || current == '\'') && next != null && next.isReadAloudStressableLetter()) { + append(next) + append(CombiningAcuteAccent) + index += 2 + } else { + append(current) + index += 1 + } + } +} + +private fun Char.isReadAloudStressableLetter(): Boolean = + this in ReadAloudStressableLetters + private fun String.sentenceRanges(): List { val ranges = mutableListOf() var start = 0 @@ -838,9 +865,10 @@ private fun String.sentenceRanges(): List { } private fun String.sentenceTerminatorEnd(index: Int): Int { - if (this[index] != '.') return index + 1 var end = index + 1 - while (end < length && this[end] == '.') end += 1 + if (this[index] == '.' || this[index] == '!' || this[index] == '?') { + while (end < length && this[end] == '.') end += 1 + } return end } @@ -854,6 +882,14 @@ private const val HeadingPauseBeforeMillis = 1_000L private const val HeadingPauseAfterMillis = 600L private const val StarBreakPauseMillis = 1_200L private const val EllipsisPauseAfterMillis = 350L +private const val CombiningAcuteAccent = '\u0301' +private const val ReadAloudStressableLetters = "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouyAEIOUY" + +private val ReadAloudHardcodedTextReplacements = listOf( + ReadAloudTextReplacement("Господа,", "Господ/а,"), + ReadAloudTextReplacement("господа", "господ/а"), + ReadAloudTextReplacement("прошуршала", "прошурш/ала"), +) private fun List.flattenSections(depth: Int = 0): List = flatMapIndexed { index, section -> diff --git a/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt index a5b63bd..54793e0 100644 --- a/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt +++ b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt @@ -53,6 +53,85 @@ class ReadAloudContentPlanTest { assertEquals(listOf(350L, 350L, 350L, 0L), plan.sentences.map { it.pauseAfterMillis }) } + @Test + fun dotsAfterQuestionOrExclamationAreRemovedForSpeech() { + val plan = buildReaderContentPlan( + Fb2Book( + title = "Book", + sections = listOf( + Fb2Section(blocks = listOf(paragraph("What?.. No!.. Done."))), + ), + ), + ) + + assertEquals( + listOf("What?..", "No!..", "Done."), + plan.sentences.map { it.text }, + ) + assertEquals( + listOf("What?", "No!", "Done."), + plan.sentences.map { it.spokenText }, + ) + } + + @Test + fun hardcodedReadAloudReplacementCanMarkStress() { + val plan = buildReaderContentPlan( + Fb2Book( + title = "Book", + sections = listOf( + Fb2Section(blocks = listOf(paragraph("Господа, идите."))), + ), + ), + ) + + assertEquals("Господа, идите.", plan.sentences.single().text) + assertEquals("Господа́, идите.", plan.sentences.single().spokenText) + } + + @Test + fun stressMarkersSupportRussianAndEnglishVowels() { + val plan = buildReaderContentPlan( + Fb2Book( + title = "Book", + sections = listOf( + Fb2Section( + blocks = listOf( + paragraph( + "/а/е/ё/и/о/у/ы/э/ю/я " + + "/А/Е/Ё/И/О/У/Ы/Э/Ю/Я " + + "'a'e'i'o'u'y " + + "'A'E'I'O'U'Y.", + ), + ), + ), + ), + ), + ) + + assertEquals( + "а́е́ё́и́о́у́ы́э́ю́я́ " + + "А́Е́Ё́И́О́У́Ы́Э́Ю́Я́ " + + "áéíóúý " + + "ÁÉÍÓÚÝ.", + plan.sentences.single().spokenText, + ) + } + + @Test + fun stressMarkersIgnoreNonStressableLetters() { + val plan = buildReaderContentPlan( + Fb2Book( + title = "Book", + sections = listOf( + Fb2Section(blocks = listOf(paragraph("/б 'z /1."))), + ), + ), + ) + + assertEquals("/б 'z /1.", plan.sentences.single().spokenText) + } + private fun paragraph(text: String): Fb2Block.Paragraph = Fb2Block.Paragraph(Fb2Text(listOf(Fb2TextSpan(text)))) }