better stress in some russian words
This commit is contained in:
parent
ed66ea0d55
commit
b0f45aaf1b
@ -80,6 +80,8 @@ private object AndroidReadAloudEngine {
|
|||||||
private const val SelectedEngineKey = "selected_engine"
|
private const val SelectedEngineKey = "selected_engine"
|
||||||
private const val EngineChoiceMadeKey = "engine_choice_made"
|
private const val EngineChoiceMadeKey = "engine_choice_made"
|
||||||
private const val SelectedVoiceKey = "selected_voice"
|
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 RussianLocale = Locale.forLanguageTag("ru-RU")
|
||||||
|
|
||||||
private val mutableState = MutableStateFlow(ReadAloudState())
|
private val mutableState = MutableStateFlow(ReadAloudState())
|
||||||
@ -153,7 +155,12 @@ private object AndroidReadAloudEngine {
|
|||||||
|
|
||||||
fun refreshSettings(context: Context) {
|
fun refreshSettings(context: Context) {
|
||||||
val appContext = context.applicationContext
|
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()
|
engineProbe?.shutdown()
|
||||||
var probe: TextToSpeech? = null
|
var probe: TextToSpeech? = null
|
||||||
probe = TextToSpeech(appContext) { status ->
|
probe = TextToSpeech(appContext) { status ->
|
||||||
@ -182,6 +189,7 @@ private object AndroidReadAloudEngine {
|
|||||||
mutableSettingsState.value = mutableSettingsState.value.copy(
|
mutableSettingsState.value = mutableSettingsState.value.copy(
|
||||||
engines = engines,
|
engines = engines,
|
||||||
selectedEngineId = selectedEngine,
|
selectedEngineId = selectedEngine,
|
||||||
|
textReplacements = textReplacements,
|
||||||
)
|
)
|
||||||
probe?.shutdown()
|
probe?.shutdown()
|
||||||
if (engineProbe === probe) engineProbe = null
|
if (engineProbe === probe) engineProbe = null
|
||||||
@ -419,6 +427,14 @@ private object AndroidReadAloudEngine {
|
|||||||
private fun selectedVoiceId(context: Context): String? =
|
private fun selectedVoiceId(context: Context): String? =
|
||||||
context.readAloudPrefs().getString(SelectedVoiceKey, null)
|
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() =
|
private fun Context.readAloudPrefs() =
|
||||||
getSharedPreferences(SettingsPrefs, Context.MODE_PRIVATE)
|
getSharedPreferences(SettingsPrefs, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
|||||||
@ -31,11 +31,17 @@ data class ReadAloudVoiceOption(
|
|||||||
val networkRequired: Boolean,
|
val networkRequired: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ReadAloudTextReplacement(
|
||||||
|
val from: String,
|
||||||
|
val to: String,
|
||||||
|
)
|
||||||
|
|
||||||
data class ReadAloudSettingsState(
|
data class ReadAloudSettingsState(
|
||||||
val engines: List<ReadAloudEngineOption> = emptyList(),
|
val engines: List<ReadAloudEngineOption> = emptyList(),
|
||||||
val selectedEngineId: String? = null,
|
val selectedEngineId: String? = null,
|
||||||
val voices: List<ReadAloudVoiceOption> = emptyList(),
|
val voices: List<ReadAloudVoiceOption> = emptyList(),
|
||||||
val selectedVoiceId: String? = null,
|
val selectedVoiceId: String? = null,
|
||||||
|
val textReplacements: List<ReadAloudTextReplacement> = emptyList(),
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -799,10 +799,37 @@ private fun String.isReadAloudPauseBreak(): Boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toReadAloudSpokenText(): String =
|
private fun String.toReadAloudSpokenText(): String =
|
||||||
replace(Regex("\\.{2,}"), ".")
|
applyReadAloudTextReplacements(ReadAloudHardcodedTextReplacements)
|
||||||
|
.withReadAloudStressMarkers()
|
||||||
|
.replace(Regex("\\.{2,}"), ".")
|
||||||
|
.replace(Regex("([!?])\\.+"), "$1")
|
||||||
.replace('…', '.')
|
.replace('…', '.')
|
||||||
.trim()
|
.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> {
|
private fun String.sentenceRanges(): List<ReaderSentenceRange> {
|
||||||
val ranges = mutableListOf<ReaderSentenceRange>()
|
val ranges = mutableListOf<ReaderSentenceRange>()
|
||||||
var start = 0
|
var start = 0
|
||||||
@ -838,9 +865,10 @@ private fun String.sentenceRanges(): List<ReaderSentenceRange> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun String.sentenceTerminatorEnd(index: Int): Int {
|
private fun String.sentenceTerminatorEnd(index: Int): Int {
|
||||||
if (this[index] != '.') return index + 1
|
|
||||||
var end = index + 1
|
var end = index + 1
|
||||||
|
if (this[index] == '.' || this[index] == '!' || this[index] == '?') {
|
||||||
while (end < length && this[end] == '.') end += 1
|
while (end < length && this[end] == '.') end += 1
|
||||||
|
}
|
||||||
return end
|
return end
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -854,6 +882,14 @@ private const val HeadingPauseBeforeMillis = 1_000L
|
|||||||
private const val HeadingPauseAfterMillis = 600L
|
private const val HeadingPauseAfterMillis = 600L
|
||||||
private const val StarBreakPauseMillis = 1_200L
|
private const val StarBreakPauseMillis = 1_200L
|
||||||
private const val EllipsisPauseAfterMillis = 350L
|
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> =
|
private fun List<Fb2Section>.flattenSections(depth: Int = 0): List<ChapterEntry> =
|
||||||
flatMapIndexed { index, section ->
|
flatMapIndexed { index, section ->
|
||||||
|
|||||||
@ -53,6 +53,85 @@ class ReadAloudContentPlanTest {
|
|||||||
assertEquals(listOf(350L, 350L, 350L, 0L), plan.sentences.map { it.pauseAfterMillis })
|
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 =
|
private fun paragraph(text: String): Fb2Block.Paragraph =
|
||||||
Fb2Block.Paragraph(Fb2Text(listOf(Fb2TextSpan(text))))
|
Fb2Block.Paragraph(Fb2Text(listOf(Fb2TextSpan(text))))
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user