Improve Android read aloud TTS handling

This commit is contained in:
sergeych committed 2026-05-21 11:07:42 +03:00
1 parent df90b4c36f
commit 8bb920caf8
11 files changed
+870 -71

No files matched your search

@@ -29,22 +29,23 @@ class HyphenationRegistry(
private fun hyphenate(text: String, plugin: HyphenationPlugin): String = buildString(text.length + text.length / 12) {
var wordStart = -1
fun flushWord(end: Int) {
fun flushToken(end: Int) {
if (wordStart >= 0) {
append(plugin.hyphenateWord(text.substring(wordStart, end)))
val token = text.substring(wordStart, end)
append(if (SoftHyphen !in token && token.all(Char::isLetter)) plugin.hyphenateWord(token) else token)
wordStart = -1
}
}
text.forEachIndexed { index, char ->
if (char.isLetter()) {
if (char.isLetterOrDigit() || char == SoftHyphen) {
if (wordStart < 0) wordStart = index
} else {
flushWord(index)
flushToken(index)
append(char)
}
}
flushWord(text.length)
flushToken(text.length)
}
}
@@ -71,19 +72,124 @@ object RussianHyphenationPlugin : HyphenationPlugin {
override val languageTags: Set<String> = setOf("ru", "rus")
override fun hyphenateWord(word: String): String {
if (word.length < 6 || SoftHyphen in word) return word
val breaks = mutableListOf<Int>()
for (index in 2 until word.lastIndex) {
val prev = word[index - 1]
val current = word[index]
val next = word[index + 1]
if (current.isRussianVowel() && next.isRussianConsonant()) breaks += index + 1
if (prev.isRussianVowel() && current.isRussianConsonant() && next.isRussianVowel()) breaks += index
if (SoftHyphen in word || !word.isRussianWord() || word.isLikelyAbbreviation()) return word
val lower = word.lowercase()
val candidates = mutableSetOf<Int>()
addRussianPrefixBreak(lower, candidates)
addRussianSyllableBreaks(lower, candidates)
return insertBreaks(word, candidates.filter { lower.isLegalRussianBreak(it) }, minPrefix = 2, minSuffix = 2)
}
private fun addRussianPrefixBreak(word: String, candidates: MutableSet<Int>) {
RussianPrefixes.firstOrNull { prefix -> word.startsWith(prefix) }
?.length
?.let(candidates::add)
}
private fun addRussianSyllableBreaks(word: String, candidates: MutableSet<Int>) {
val vowelIndexes = word.indices.filter { word[it].isRussianVowel() }
vowelIndexes.zipWithNext().forEach { (leftVowel, rightVowel) ->
val clusterStart = leftVowel + 1
val clusterEnd = rightVowel
val clusterLength = clusterEnd - clusterStart
when {
clusterLength == 0 -> candidates += rightVowel
clusterLength == 1 -> {
val consonant = word[clusterStart]
candidates += if (consonant.isRussianJoiner()) clusterStart + 1 else clusterStart
}
clusterLength == 2 -> {
val joiner = (clusterStart until clusterEnd).firstOrNull { word[it].isRussianJoiner() }
candidates += if (joiner != null) joiner + 1 else clusterStart + 1
}
else -> addRussianClusterBreak(word, clusterStart, clusterEnd, candidates)
}
}
return insertBreaks(word, breaks, minPrefix = 2, minSuffix = 2)
}
private fun addRussianClusterBreak(
word: String,
clusterStart: Int,
clusterEnd: Int,
candidates: MutableSet<Int>,
) {
val cluster = word.substring(clusterStart, clusterEnd)
val joiner = (clusterStart until clusterEnd).firstOrNull { word[it].isRussianJoiner() }
if (joiner != null) {
if (joiner < clusterEnd - 1) candidates += joiner + 1
return
}
if (cluster == "ств") {
candidates += clusterStart + 2
return
}
if (cluster.endsWith("ств")) {
candidates += clusterEnd - 3
return
}
val onsetBreak = (clusterStart until clusterEnd)
.firstOrNull { index -> RussianRightOnsets.any { onset -> word.startsWith(onset, index) } }
candidates += onsetBreak ?: (clusterEnd - 1)
}
}
private val RussianPrefixes = listOf(
"сверх",
"подъ",
"пере",
"пред",
"без",
"бес",
"воз",
"вос",
"низ",
"нис",
"раз",
"рас",
"роз",
"рос",
"под",
"при",
"про",
"над",
"меж",
"вз",
"вс",
"из",
"ис",
"от",
"об",
)
private val RussianRightOnsets = listOf(
"ст",
"ск",
"сп",
"сн",
"сл",
"см",
"св",
"бр",
"бл",
"вр",
"вл",
"гр",
"гл",
"др",
"тр",
"кр",
"кл",
"пр",
"пл",
"фр",
"фл",
)
const val SoftHyphen: Char = '\u00AD'
private fun insertBreaks(word: String, breaks: List<Int>, minPrefix: Int, minSuffix: Int): String {
@@ -107,4 +213,24 @@ private fun Char.isConsonant(): Boolean = isLetter() && !isVowel()
private fun Char.isRussianVowel(): Boolean = lowercaseChar() in "аеёиоуыэюя"
private fun Char.isRussianConsonant(): Boolean =
lowercaseChar() in "бвгджзйклмнпрстфхцчшщ"
lowercaseChar() in "бвгджзклмнпрстфхцчшщ"
private fun Char.isRussianJoiner(): Boolean = lowercaseChar() in "йьъ"
private fun Char.isRussianLetter(): Boolean = lowercaseChar() in "абвгдеёжзийклмнопрстуфхцчшщъыьэюя"
private fun String.isRussianWord(): Boolean = all(Char::isRussianLetter)
private fun String.isLikelyAbbreviation(): Boolean = length > 1 && all { it.isUpperCase() }
private fun String.isLegalRussianBreak(index: Int): Boolean {
if (index < 2 || length - index < 2) return false
if (take(index).none(Char::isRussianVowel) || drop(index).none(Char::isRussianVowel)) return false
val left = this[index - 1]
val right = this[index]
if (right.lowercaseChar() in "ьъйы") return false
if (right.isRussianJoiner()) return false
if (left.isRussianConsonant() && right.isRussianVowel()) return false
return true
}
@@ -1,13 +1,16 @@
package net.sergeych.toread.text
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class HyphenationTest {
private val hyphenation = HyphenationRegistry()
@Test
fun selectsEnglishPluginByLanguage() {
val hyphenated = HyphenationRegistry().hyphenate("composition", "en")
val hyphenated = hyphenation.hyphenate("composition", "en")
assertNotEquals("composition", hyphenated)
assertTrue(SoftHyphen in hyphenated)
@@ -15,9 +18,41 @@ class HyphenationTest {
@Test
fun selectsRussianPluginByLanguage() {
val hyphenated = HyphenationRegistry().hyphenate("повествование", "ru")
val hyphenated = hyphenation.hyphenate("повествование", "ru")
assertNotEquals("повествование", hyphenated)
assertTrue(SoftHyphen in hyphenated)
}
@Test
fun suggestsRussianHyphensAtReadableBreaks() {
mapOf(
"молоко" to "мо-ло-ко",
"корова" to "ко-ро-ва",
"яблоко" to "яб-ло-ко",
"повествование" to "по-вест-во-ва-ние",
"предложение" to "пред-ло-же-ние",
"компьютер" to "компью-тер",
"подъезд" to "подъ-езд",
"большой" to "боль-шой",
"майор" to "май-ор",
"масса" to "мас-са",
"длинный" to "длин-ный",
"разыскать" to "ра-зыс-кать",
"розыгрыш" to "ро-зыг-рыш",
).forEach { (word, expected) ->
assertEquals(expected, word.hyphenatedRu())
}
}
@Test
fun keepsRussianWordsWithoutLegalReadableBreaksUnchanged() {
listOf("дом", "мир", "стол", "семья", "СССР", "testовый", "книга123").forEach { word ->
assertEquals(word, word.hyphenatedRu())
}
assertEquals("ко-рова", "ко${SoftHyphen}рова".hyphenatedRu())
}
private fun String.hyphenatedRu(): String =
hyphenation.hyphenate(this, "ru").replace(SoftHyphen, '-')
}