better stress in some russian words

This commit is contained in:
Sergey Chernov 2026-05-23 22:24:47 +03:00
parent ed66ea0d55
commit b0f45aaf1b
4 changed files with 141 additions and 4 deletions

View File

@ -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<ReadAloudTextReplacement> {
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)

View File

@ -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<ReadAloudEngineOption> = emptyList(),
val selectedEngineId: String? = null,
val voices: List<ReadAloudVoiceOption> = emptyList(),
val selectedVoiceId: String? = null,
val textReplacements: List<ReadAloudTextReplacement> = emptyList(),
val loading: Boolean = false,
val message: String? = null,
)

View File

@ -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<ReadAloudTextReplacement>): 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<ReaderSentenceRange> {
val ranges = mutableListOf<ReaderSentenceRange>()
var start = 0
@ -838,9 +865,10 @@ private fun String.sentenceRanges(): List<ReaderSentenceRange> {
}
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<Fb2Section>.flattenSections(depth: Int = 0): List<ChapterEntry> =
flatMapIndexed { index, section ->

View File

@ -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))))
}