feat: add expressive offline Russian book TTS

This commit is contained in:
Курнат Андрей
2026-07-21 21:28:48 +03:00
parent 28312e3fbc
commit cf82ab61eb
47 changed files with 127843 additions and 124 deletions
+8
View File
@@ -18,6 +18,9 @@ android {
vectorDrawables {
useSupportLibrary = true
}
ndk {
abiFilters += "arm64-v8a"
}
}
buildTypes {
@@ -53,6 +56,10 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
androidResources {
noCompress += "onnx"
}
}
dependencies {
@@ -67,6 +74,7 @@ dependencies {
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.webkit:webkit:1.12.1")
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.27.0")
testImplementation("junit:junit:4.13.2")
}
+4
View File
@@ -5,3 +5,7 @@
# Keep the manifest-declared Argus package installer callback constructable by Android.
-keep class xyz.kusoft.argusupdater.ArgusPackageInstallerStatusReceiver { public <init>(); }
# ONNX Runtime's native JNI layer resolves these Java classes and members by
# their original names when converting native OrtValues to Java OnnxValues.
-keep class ai.onnxruntime.** { *; }
+85 -3
View File
@@ -301,6 +301,68 @@
}
}
function visibleTextSlice(element, width, height) {
const doc = element && element.ownerDocument;
if (!doc || typeof doc.createTreeWalker !== 'function' || typeof doc.createRange !== 'function') return '';
const nodeFilter = (doc.defaultView && doc.defaultView.NodeFilter) || global.NodeFilter;
if (!nodeFilter) return '';
const walker = doc.createTreeWalker(element, nodeFilter.SHOW_TEXT);
const range = doc.createRange();
let raw = '';
let visibleStart = Number.POSITIVE_INFINITY;
let visibleEnd = -1;
let node;
while ((node = walker.nextNode())) {
const value = String(node.nodeValue || '');
const base = raw.length;
raw += value;
const words = /\S+/g;
let match;
while ((match = words.exec(value))) {
range.setStart(node, match.index);
range.setEnd(node, match.index + match[0].length);
const visible = Array.from(range.getClientRects()).some(function (rect) {
return rect.width > 0 && rect.height > 0 &&
rect.right > 0 && rect.bottom > 0 && rect.left < width && rect.top < height;
});
if (!visible) continue;
visibleStart = Math.min(visibleStart, base + match.index);
visibleEnd = Math.max(visibleEnd, base + match.index + match[0].length);
}
}
range.detach();
if (!Number.isFinite(visibleStart) || visibleEnd <= visibleStart) return '';
let start = visibleStart;
const before = raw.slice(0, start).replace(/\s+$/g, '');
if (before && !/[.!?…][\"'»”)]?$/.test(before)) {
const remainder = raw.slice(start, visibleEnd);
const nextSentence = /[.!?…]+[\"'»”)]?\s+/.exec(remainder);
if (nextSentence) {
const candidate = start + nextSentence.index + nextSentence[0].length;
if (candidate < visibleEnd) start = candidate;
}
}
return raw.slice(start, visibleEnd).replace(/\s+/g, ' ').trim();
}
function rangeSpeechText(range) {
if (!range || typeof range.cloneContents !== 'function') return '';
const doc = range.startContainer && range.startContainer.ownerDocument;
if (!doc) return String(range.toString() || '').trim();
const container = doc.createElement('div');
container.appendChild(range.cloneContents());
const blockSelector = 'p,h1,h2,h3,h4,h5,h6,pre,blockquote,li,td,th';
Array.from(container.querySelectorAll(blockSelector)).forEach(function (element) {
if (element.querySelector(blockSelector)) return;
element.appendChild(doc.createTextNode('\n'));
});
return String(container.textContent || '')
.replace(/[\t\f\v ]+/g, ' ')
.replace(/ *\n+ */g, '\n')
.trim();
}
function visibleSpeechText(documents, maxCharacters) {
const limit = clamp(Number(maxCharacters) || 3500, 200, 3500);
const blocks = [];
@@ -318,13 +380,32 @@
rect.right > 0 && rect.bottom > 0 && rect.left < width && rect.top < height;
});
if (!visible) return;
const text = String(element.innerText || element.textContent || '')
.replace(/\s+/g, ' ')
.trim();
const text = visibleTextSlice(element, width, height);
if (!text || seen.has(text)) return;
seen.add(text);
blocks.push(text);
});
if (blocks.length || typeof doc.elementFromPoint !== 'function') return;
// EPUB pagination can place the current column in a clipped iframe where
// getClientRects() does not intersect the document's nominal viewport.
// Sampling the actual hit-tested viewport reliably finds the paragraphs
// the reader is displaying without reading hidden chapters or controls.
const blockSelector = 'p,h1,h2,h3,h4,h5,h6,pre,blockquote,li,td,th';
const xSamples = [0.12, 0.5, 0.88];
const ySamples = [0.08, 0.22, 0.38, 0.54, 0.70, 0.86, 0.96];
ySamples.forEach(function (yRatio) {
xSamples.forEach(function (xRatio) {
const hit = doc.elementFromPoint(width * xRatio, height * yRatio);
const element = hit && hit.closest ? hit.closest(blockSelector) : null;
if (!element) return;
const text = visibleTextSlice(element, width, height) ||
String(element.innerText || element.textContent || '').replace(/\s+/g, ' ').trim();
if (!text || seen.has(text)) return;
seen.add(text);
blocks.push(text);
});
});
});
let text = blocks.join('\n').trim();
if (text.length <= limit) return text;
@@ -363,5 +444,6 @@
Internal.installReaderFontFaces = installReaderFontFaces;
Internal.waitForPreferredFont = waitForPreferredFont;
Internal.validExternalUrl = validExternalUrl;
Internal.rangeSpeechText = rangeSpeechText;
Internal.visibleSpeechText = visibleSpeechText;
})(window);
@@ -566,10 +566,95 @@
const contents = this.rendition && this.rendition.getContents
? this.rendition.getContents()
: [];
const location = this.rendition && typeof this.rendition.currentLocation === 'function'
? this.rendition.currentLocation()
: this.currentLocation;
const startCfi = location && location.start && location.start.cfi;
const endCfi = location && location.end && location.end.cfi;
const limit = Internal.clamp(Number(maxCharacters) || 3500, 200, 3500);
if (startCfi && endCfi) {
for (let index = 0; index < contents.length; index += 1) {
const item = contents[index];
if (!item || typeof item.range !== 'function' || !item.document) continue;
try {
const start = item.range(startCfi);
const end = item.range(endCfi);
if (!start || !end) continue;
const pageRange = item.document.createRange();
pageRange.setStart(start.startContainer, start.startOffset);
pageRange.setEnd(end.endContainer, end.endOffset);
const pageText = Internal.rangeSpeechText(pageRange);
let text = pageText;
let extendedCharacters = 0;
if (text && !/[.!?…][\"'»”)]?\s*$/.test(text)) {
const continuationRange = item.document.createRange();
continuationRange.setStart(end.endContainer, end.endOffset);
continuationRange.selectNodeContents(item.document.body);
continuationRange.setStart(end.endContainer, end.endOffset);
const continuation = Internal.rangeSpeechText(continuationRange).trimStart();
const sentenceEnd = /^[\s\S]*?[.!?…]+[\"'»”)]?/.exec(continuation);
if (sentenceEnd) {
const extension = sentenceEnd[0].trimEnd();
text = (text + ' ' + extension).trim();
extendedCharacters = extension.length;
}
}
const startNode = start.startContainer;
const startElement = startNode && (startNode.nodeType === 3 ? startNode.parentElement : startNode);
const startBlock = startElement && startElement.closest
? startElement.closest('p,h1,h2,h3,h4,h5,h6,pre,blockquote,li,td,th')
: null;
let textBeforeStart = '';
if (startBlock) {
const beforeRange = item.document.createRange();
beforeRange.selectNodeContents(startBlock);
beforeRange.setEnd(start.startContainer, start.startOffset);
textBeforeStart = String(beforeRange.toString() || '').trimEnd();
} else if (startNode && startNode.nodeType === 3 && start.startOffset > 0) {
textBeforeStart = String(startNode.nodeValue || '').slice(0, start.startOffset).trimEnd();
}
const startsInsideSentence = Boolean(textBeforeStart) &&
!/[.!?…][\"'»”)]?\s*$/.test(textBeforeStart);
if (startsInsideSentence) {
const boundary = /[.!?…]+[\"'»”)]?\s+/.exec(text);
if (boundary && boundary.index + boundary[0].length < text.length) {
text = text.slice(boundary.index + boundary[0].length).trim();
}
}
if (text.length > limit) {
const candidate = text.slice(0, limit + 1);
const sentenceEnd = Math.max(
candidate.lastIndexOf('. '),
candidate.lastIndexOf('! '),
candidate.lastIndexOf('? '),
candidate.lastIndexOf('… ')
);
const wordEnd = candidate.lastIndexOf(' ', limit);
const cut = sentenceEnd >= Math.floor(limit * .55)
? sentenceEnd + 1
: (wordEnd > 0 ? wordEnd : limit);
text = candidate.slice(0, cut).trim();
}
if (text) {
return {
text: text,
locator: this.currentLocator(),
canAdvance: this.currentProgress < .9999,
source: 'epub-cfi-page',
pageCharacters: pageText.length,
extendedCharacters: extendedCharacters
};
}
} catch (_) {}
}
}
return {
text: Internal.visibleSpeechText(contents.map(function (item) { return item.document; }), maxCharacters),
locator: this.currentLocator(),
canAdvance: this.currentProgress < .9999
canAdvance: this.currentProgress < .9999,
source: 'visible-fallback',
pageCharacters: 0,
extendedCharacters: 0
};
}
+4 -3
View File
@@ -222,9 +222,10 @@
},
getSpeechPageJson: function (maxCharacters) {
try {
return JSON.stringify(callEngine('speechPage', [maxCharacters], {
text: '', locator: null, canAdvance: false
}));
const page = state.engine && typeof state.engine.speechPage === 'function'
? state.engine.speechPage(maxCharacters)
: { text: '', locator: null, canAdvance: false };
return JSON.stringify(page);
} catch (error) {
Internal.reportError(error, 'api.getSpeechPageJson', true);
return JSON.stringify({ text: '', locator: null, canAdvance: false });
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Binary file not shown.
Binary file not shown.
1 version https://git-lfs.github.com/spec/v1
2 oid sha256:db5eaadd86c5fde2b68b371bafbf07d2102de3bef861fed3468456a839190378
3 size 96070019
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -520,7 +520,7 @@ class ReaderActivity : AppCompatActivity() {
binding.readerVoiceButton.contentDescription = if (active) {
"Приостановить озвучивание"
} else {
"Слушать книгу. Удерживайте для настройки скорости и интонации"
"Слушать книгу. Удерживайте для настройки голоса, темпа и пауз"
}
}
@@ -401,7 +401,10 @@ class ReaderWebController(
data class ReaderSpeechPage(
val text: String,
val locator: String?,
val canAdvance: Boolean
val canAdvance: Boolean,
val source: String,
val pageCharacters: Int,
val extendedCharacters: Int
) {
companion object {
internal fun fromJson(json: JSONObject): ReaderSpeechPage? {
@@ -415,7 +418,10 @@ data class ReaderSpeechPage(
return ReaderSpeechPage(
text = text,
locator = locator,
canAdvance = json.optBoolean("canAdvance", true)
canAdvance = json.optBoolean("canAdvance", true),
source = json.optString("source", "unknown"),
pageCharacters = json.optInt("pageCharacters", text.length),
extendedCharacters = json.optInt("extendedCharacters", 0)
)
}
}
@@ -1,25 +1,15 @@
package com.aletheia.app.ui.reader.tts
import android.content.Context
import android.media.AudioAttributes
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import com.aletheia.app.ui.reader.ReaderSpeechPage
import com.aletheia.app.ui.reader.ReaderWebController
import java.util.Locale
import java.util.UUID
import java.util.concurrent.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Reads book pages through an installed Russian voice that explicitly declares
* that it does not require a network connection.
*
* The controller deliberately refuses network-only voices. A project-owned
* neural model can replace this engine behind the same reader-page contract.
*/
class ReaderSpeechController(
context: Context,
private val scope: CoroutineScope,
@@ -31,10 +21,11 @@ class ReaderSpeechController(
private val appContext = context.applicationContext
private val preferences = appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
private var tts: TextToSpeech? = null
private val engine = VoskSpeechEngine(appContext)
private var state = State.IDLE
private var initializationComplete = false
private var resumeAfterInitialization = false
private var initializationJob: Job? = null
private var playbackJob: Job? = null
private var currentPage: ReaderSpeechPage? = null
private var lastSpokenSignature: String? = null
@@ -45,6 +36,16 @@ class ReaderSpeechController(
val pitch: Float
get() = preferences.getFloat(KEY_PITCH, DEFAULT_PITCH).coerceIn(MIN_PITCH, MAX_PITCH)
val pauseScale: Float
get() = preferences.getFloat(KEY_PAUSE_SCALE, DEFAULT_PAUSE_SCALE).coerceIn(MIN_PAUSE_SCALE, MAX_PAUSE_SCALE)
val articulationScale: Float
get() = preferences.getFloat(KEY_ARTICULATION_SCALE, DEFAULT_ARTICULATION_SCALE)
.coerceIn(MIN_ARTICULATION_SCALE, MAX_ARTICULATION_SCALE)
val speakerId: Int
get() = preferences.getInt(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID).coerceIn(0, VOICES.lastIndex)
fun toggle() {
when (state) {
State.PLAYING, State.INITIALIZING -> pause()
@@ -58,16 +59,17 @@ class ReaderSpeechController(
initialize()
return
}
val resumePausedPage = state == State.PAUSED
currentPage = null
lastSpokenSignature = null
updateState(State.PLAYING)
speakCurrentPage(reusePausedPage = resumePausedPage)
speakCurrentPage(reusePausedPage = false)
}
fun pause() {
resumeAfterInitialization = false
playbackJob?.cancel()
playbackJob = null
tts?.stop()
engine.stop()
updateState(State.PAUSED)
}
@@ -75,94 +77,61 @@ class ReaderSpeechController(
resumeAfterInitialization = false
playbackJob?.cancel()
playbackJob = null
tts?.stop()
engine.stop()
currentPage = null
lastSpokenSignature = null
updateState(State.IDLE)
}
fun updateSettings(rate: Float, pitch: Float) {
fun updateSettings(
rate: Float,
pitch: Float,
pauseScale: Float,
articulationScale: Float,
speakerId: Int
) {
preferences.edit()
.putFloat(KEY_RATE, rate.coerceIn(MIN_RATE, MAX_RATE))
.putFloat(KEY_PITCH, pitch.coerceIn(MIN_PITCH, MAX_PITCH))
.putFloat(KEY_PAUSE_SCALE, pauseScale.coerceIn(MIN_PAUSE_SCALE, MAX_PAUSE_SCALE))
.putFloat(
KEY_ARTICULATION_SCALE,
articulationScale.coerceIn(MIN_ARTICULATION_SCALE, MAX_ARTICULATION_SCALE)
)
.putInt(KEY_SPEAKER_ID, speakerId.coerceIn(0, VOICES.lastIndex))
.apply()
applySpeechSettings()
if (state == State.PLAYING) {
tts?.stop()
engine.stop()
speakCurrentPage(reusePausedPage = true)
}
}
fun destroy() {
initializationJob?.cancel()
playbackJob?.cancel()
initializationJob = null
playbackJob = null
tts?.stop()
tts?.shutdown()
tts = null
engine.destroy()
initializationComplete = false
}
private fun initialize() {
if (tts != null) {
updateState(State.INITIALIZING)
return
}
if (initializationJob?.isActive == true) return
updateState(State.INITIALIZING)
tts = TextToSpeech(appContext) { status ->
if (status != TextToSpeech.SUCCESS) {
fail("Не удалось запустить синтез речи на устройстве")
return@TextToSpeech
}
val engine = tts ?: return@TextToSpeech
val russianLocale = Locale.Builder().setLanguage("ru").setRegion("RU").build()
if (engine.setLanguage(russianLocale) < TextToSpeech.LANG_AVAILABLE) {
fail("Русский язык не поддерживается установленным синтезатором речи")
return@TextToSpeech
}
val offlineRussianVoice = engine.voices
?.asSequence()
?.filter { it.locale.language.equals("ru", ignoreCase = true) }
?.filterNot { it.isNetworkConnectionRequired }
?.sortedWith(compareByDescending<android.speech.tts.Voice> { it.quality }.thenBy { it.name })
?.firstOrNull()
if (offlineRussianVoice == null) {
fail("На телефоне не установлен русский офлайн-голос")
return@TextToSpeech
}
if (engine.setVoice(offlineRussianVoice) == TextToSpeech.ERROR) {
fail("Не удалось выбрать русский офлайн-голос")
return@TextToSpeech
}
engine.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) = Unit
override fun onDone(utteranceId: String?) {
scope.launch { advanceAfterPage() }
initializationJob = scope.launch {
try {
engine.initialize()
initializationComplete = true
if (resumeAfterInitialization) {
resumeAfterInitialization = false
updateState(State.PLAYING)
speakCurrentPage(reusePausedPage = false)
} else {
updateState(State.IDLE)
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
scope.launch { fail("Ошибка синтеза речи") }
}
override fun onError(utteranceId: String?, errorCode: Int) {
scope.launch { fail("Ошибка синтеза речи: $errorCode") }
}
})
initializationComplete = true
applySpeechSettings()
if (resumeAfterInitialization) {
resumeAfterInitialization = false
updateState(State.PLAYING)
speakCurrentPage(reusePausedPage = false)
} else {
updateState(State.IDLE)
} catch (error: Throwable) {
Log.e(TAG, "Offline voice initialization failed", error)
fail("Не удалось запустить встроенный офлайн-голос Vosk: ${error.message ?: error.javaClass.simpleName}")
}
}
}
@@ -170,29 +139,37 @@ class ReaderSpeechController(
private fun speakCurrentPage(reusePausedPage: Boolean) {
playbackJob?.cancel()
playbackJob = scope.launch {
val page = if (reusePausedPage) currentPage else null
?: reader.getSpeechPage(TextToSpeech.getMaxSpeechInputLength().coerceAtMost(MAX_PAGE_CHARS))
if (state != State.PLAYING) return@launch
if (page == null || page.text.isBlank()) {
fail("На текущей странице нет текста для озвучивания")
return@launch
try {
val page = if (reusePausedPage) currentPage else null
?: reader.getSpeechPage(MAX_PAGE_CHARS)
if (state != State.PLAYING) return@launch
if (page == null || page.text.isBlank()) {
fail("На текущей странице нет текста для озвучивания")
return@launch
}
Log.d(
TAG,
"Speech page extracted: chars=${page.text.length}, pageChars=${page.pageCharacters}, " +
"extended=${page.extendedCharacters}, canAdvance=${page.canAdvance}, source=${page.source}"
)
currentPage = page
val signature = page.signature()
if (!reusePausedPage && signature == lastSpokenSignature) {
stop()
onMessage("Достигнут конец книги")
return@launch
}
lastSpokenSignature = signature
engine.speak(page.text, rate, pitch, pauseScale, articulationScale)
advanceAfterPage()
} catch (_: CancellationException) {
// Pause and stop cancel the active synthesis/playback job.
} catch (error: Throwable) {
if (state == State.PLAYING) {
Log.e(TAG, "Offline speech playback failed", error)
fail("Ошибка встроенного синтеза речи: ${error.message ?: error.javaClass.simpleName}")
}
}
currentPage = page
val signature = page.signature()
if (!reusePausedPage && signature == lastSpokenSignature) {
stop()
onMessage("Достигнут конец книги")
return@launch
}
lastSpokenSignature = signature
applySpeechSettings()
val result = tts?.speak(
page.text,
TextToSpeech.QUEUE_FLUSH,
null,
UUID.randomUUID().toString()
) ?: TextToSpeech.ERROR
if (result == TextToSpeech.ERROR) fail("Не удалось начать озвучивание")
}
}
@@ -210,20 +187,16 @@ class ReaderSpeechController(
if (state == State.PLAYING) speakCurrentPage(reusePausedPage = false)
}
private fun applySpeechSettings() {
tts?.setSpeechRate(rate)
tts?.setPitch(pitch)
}
private fun fail(message: String) {
playbackJob?.cancel()
playbackJob = null
tts?.stop()
engine.stop()
updateState(State.ERROR)
onMessage(message)
}
private fun updateState(next: State) {
Log.d(TAG, "State: $state -> $next")
state = next
onStateChanged(next)
}
@@ -231,15 +204,27 @@ class ReaderSpeechController(
private fun ReaderSpeechPage.signature(): String = "$locator|${text.take(160)}"
companion object {
private const val TAG = "AletheiaVoskTts"
const val MIN_RATE = 0.55f
const val MAX_RATE = 1.80f
const val MIN_PITCH = 0.75f
const val MAX_PITCH = 1.30f
const val MIN_PAUSE_SCALE = 0.50f
const val MAX_PAUSE_SCALE = 2.00f
const val MIN_ARTICULATION_SCALE = 0.90f
const val MAX_ARTICULATION_SCALE = 1.20f
val VOICES = listOf("Мужской 1")
private const val DEFAULT_RATE = 1.0f
private const val DEFAULT_PITCH = 1.0f
private const val DEFAULT_PAUSE_SCALE = 1.0f
private const val DEFAULT_ARTICULATION_SCALE = 1.06f
private const val DEFAULT_SPEAKER_ID = 2
private const val PREFERENCES_NAME = "reader_speech"
private const val KEY_RATE = "rate"
private const val KEY_PITCH = "pitch"
private const val KEY_PAUSE_SCALE = "pause_scale"
private const val KEY_ARTICULATION_SCALE = "articulation_scale"
private const val KEY_SPEAKER_ID = "speaker_id"
private const val MAX_PAGE_CHARS = 3_500
private const val PAGE_TURN_DELAY_MS = 450L
}
@@ -2,8 +2,10 @@ package com.aletheia.app.ui.reader.tts
import android.content.Context
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.SeekBar
import android.widget.Spinner
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.util.Locale
@@ -18,10 +20,25 @@ object ReaderSpeechSettingsDialog {
val vertical = (8 * density).roundToInt()
setPadding(horizontal, vertical, horizontal, vertical)
}
val voiceTitle = TextView(context).apply {
text = "Голос Vosk TTS 0.9"
textSize = 15f
setPadding(0, 16, 0, 4)
}
val voice = Spinner(context).apply {
adapter = ArrayAdapter(
context,
android.R.layout.simple_spinner_dropdown_item,
ReaderSpeechController.VOICES
)
setSelection(controller.speakerId)
}
content.addView(voiceTitle)
content.addView(voice)
val rateValue = TextView(context)
val rate = slider(
context = context,
label = "Скорость",
label = "Темп чтения",
value = controller.rate,
min = ReaderSpeechController.MIN_RATE,
max = ReaderSpeechController.MAX_RATE,
@@ -31,23 +48,54 @@ object ReaderSpeechSettingsDialog {
val pitchValue = TextView(context)
val pitch = slider(
context = context,
label = "Интонация",
label = "Высота голоса",
value = controller.pitch,
min = ReaderSpeechController.MIN_PITCH,
max = ReaderSpeechController.MAX_PITCH,
valueView = pitchValue,
parent = content
)
val pauseValue = TextView(context)
val pause = slider(
context = context,
label = "Паузы на знаках препинания",
value = controller.pauseScale,
min = ReaderSpeechController.MIN_PAUSE_SCALE,
max = ReaderSpeechController.MAX_PAUSE_SCALE,
valueView = pauseValue,
parent = content
)
val articulationValue = TextView(context)
val articulation = slider(
context = context,
label = "Разборчивость слов и букв",
value = controller.articulationScale,
min = ReaderSpeechController.MIN_ARTICULATION_SCALE,
max = ReaderSpeechController.MAX_ARTICULATION_SCALE,
valueView = articulationValue,
parent = content
)
MaterialAlertDialogBuilder(context)
.setTitle("Озвучивание книги")
.setMessage("Голос работает офлайн. Интонация регулирует высоту голоса.")
.setMessage("Все параметры применяются к встроенному офлайн-голосу. Настройки меняются плавно.")
.setView(content)
.setNegativeButton("Отмена", null)
.setPositiveButton("Сохранить") { _, _ ->
controller.updateSettings(
progressToValue(rate.progress, ReaderSpeechController.MIN_RATE, ReaderSpeechController.MAX_RATE),
progressToValue(pitch.progress, ReaderSpeechController.MIN_PITCH, ReaderSpeechController.MAX_PITCH)
progressToValue(pitch.progress, ReaderSpeechController.MIN_PITCH, ReaderSpeechController.MAX_PITCH),
progressToValue(
pause.progress,
ReaderSpeechController.MIN_PAUSE_SCALE,
ReaderSpeechController.MAX_PAUSE_SCALE
),
progressToValue(
articulation.progress,
ReaderSpeechController.MIN_ARTICULATION_SCALE,
ReaderSpeechController.MAX_ARTICULATION_SCALE
),
voice.selectedItemPosition
)
}
.show()
@@ -97,5 +145,5 @@ object ReaderSpeechSettingsDialog {
private fun progressToValue(progress: Int, min: Float, max: Float): Float =
min + (max - min) * progress.coerceIn(0, SLIDER_STEPS) / SLIDER_STEPS
private const val SLIDER_STEPS = 100
private const val SLIDER_STEPS = 300
}
@@ -0,0 +1,141 @@
package com.aletheia.app.ui.reader.tts
internal object RussianNumberNormalizer {
private val yearInPrepositionalPattern = Regex(
"(?<![\\p{L}\\p{N}])(\\d{4})\\s+(году|г\\.)",
RegexOption.IGNORE_CASE
)
private val numberPattern = Regex(
"(?<![\\p{L}\\p{N}])([+-]?)(\\d{1,18})(?:[,.](\\d+))?(?![\\p{L}\\p{N}])"
)
fun normalize(text: String): String {
val prepared = text
.replace(Regex("(?<=\\d)[\\u00A0\\u202F](?=\\d{3}(?:\\D|$))"), "")
.replace(Regex("\\s*(?=\\d)"), "номер ")
val yearsNormalized = yearInPrepositionalPattern.replace(prepared) { match ->
val year = match.groupValues[1].toInt()
"${yearInPrepositionalWords(year)} ${match.groupValues[2]}"
}
return numberPattern.replace(yearsNormalized) { match ->
val sign = when (match.groupValues[1]) {
"-" -> "минус "
"+" -> "плюс "
else -> ""
}
val integerDigits = match.groupValues[2]
val integer = if (integerDigits.length > 1 && integerDigits.startsWith('0')) {
integerDigits.toDigitWords()
} else {
integerDigits.toLongOrNull()?.let(::integerToWords) ?: integerDigits.toDigitWords()
}
val fractionDigits = match.groupValues[3]
if (fractionDigits.isBlank()) "$sign$integer" else "$sign$integer запятая ${fractionDigits.toDigitWords()}"
}
}
private fun yearInPrepositionalWords(value: Int): String {
require(value in 1000..9999)
if (value % 1000 == 0) return EXACT_THOUSANDTH_PREPOSITIONAL[value / 1000]
val thousands = value / 1000
val thousandsCardinal = underThousandToWords(thousands, feminine = true)
val thousandsWords = if (thousandsCardinal == "одна") {
SCALES[1]!!.formFor(thousands)
} else {
"$thousandsCardinal ${SCALES[1]!!.formFor(thousands)}"
}
return "$thousandsWords ${ordinalUnderThousandPrepositional(value % 1000)}"
}
private fun ordinalUnderThousandPrepositional(value: Int): String {
require(value in 1..999)
val lastTwo = value % 100
val units = value % 10
return when {
units != 0 && lastTwo !in 11..19 ->
"${integerToWords((value - units).toLong())} ${ORDINAL_UNITS_PREPOSITIONAL[units]}".trim()
lastTwo in 11..19 ->
"${integerToWords((value - lastTwo).toLong())} ${ORDINAL_TEENS_PREPOSITIONAL[lastTwo - 10]}".trim()
lastTwo >= 20 ->
"${integerToWords((value - lastTwo).toLong())} ${ORDINAL_TENS_PREPOSITIONAL[lastTwo / 10]}".trim()
else -> ORDINAL_HUNDREDS_PREPOSITIONAL[value / 100]
}
}
private fun integerToWords(value: Long): String {
if (value == 0L) return DIGITS[0]
if (value < 0L) return "минус ${integerToWords(-value)}"
val groups = mutableListOf<Int>()
var remainder = value
while (remainder > 0) {
groups += (remainder % 1_000).toInt()
remainder /= 1_000
}
return groups.indices.reversed().mapNotNull { groupIndex ->
val group = groups[groupIndex]
if (group == 0) return@mapNotNull null
val feminine = groupIndex == 1
val words = underThousandToWords(group, feminine)
val scale = SCALES.getOrNull(groupIndex)
if (scale == null) words else "$words ${scale.formFor(group)}"
}.joinToString(" ")
}
private fun underThousandToWords(value: Int, feminine: Boolean): String {
val words = mutableListOf<String>()
val hundreds = value / 100
if (hundreds > 0) words += HUNDREDS[hundreds]
val tail = value % 100
when {
tail in 10..19 -> words += TEENS[tail - 10]
else -> {
val tens = tail / 10
if (tens > 0) words += TENS[tens]
val units = tail % 10
if (units > 0) {
words += when {
feminine && units == 1 -> "одна"
feminine && units == 2 -> "две"
else -> DIGITS[units]
}
}
}
}
return words.joinToString(" ")
}
private fun String.toDigitWords(): String = map { DIGITS[it.digitToInt()] }.joinToString(" ")
private data class Scale(val one: String, val few: String, val many: String) {
fun formFor(value: Int): String {
val lastTwo = value % 100
if (lastTwo in 11..14) return many
return when (value % 10) {
1 -> one
2, 3, 4 -> few
else -> many
}
}
}
private val DIGITS = listOf("ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять")
private val TEENS = listOf(
"десять", "одиннадцать", "двенадцать", "тринадцать", "четырнадцать",
"пятнадцать", "шестнадцать", "семнадцать", "восемнадцать", "девятнадцать"
)
private val TENS = listOf("", "десять", "двадцать", "тридцать", "сорок", "пятьдесят", "шестьдесят", "семьдесят", "восемьдесят", "девяносто")
private val HUNDREDS = listOf("", "сто", "двести", "триста", "четыреста", "пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот")
private val ORDINAL_UNITS_PREPOSITIONAL = listOf("", "первом", "втором", "третьем", "четвёртом", "пятом", "шестом", "седьмом", "восьмом", "девятом")
private val ORDINAL_TEENS_PREPOSITIONAL = listOf("десятом", "одиннадцатом", "двенадцатом", "тринадцатом", "четырнадцатом", "пятнадцатом", "шестнадцатом", "семнадцатом", "восемнадцатом", "девятнадцатом")
private val ORDINAL_TENS_PREPOSITIONAL = listOf("", "десятом", "двадцатом", "тридцатом", "сороковом", "пятидесятом", "шестидесятом", "семидесятом", "восьмидесятом", "девяностом")
private val ORDINAL_HUNDREDS_PREPOSITIONAL = listOf("", "сотом", "двухсотом", "трёхсотом", "четырёхсотом", "пятисотом", "шестисотом", "семисотом", "восьмисотом", "девятисотом")
private val EXACT_THOUSANDTH_PREPOSITIONAL = listOf("", "тысячном", "двухтысячном", "трёхтысячном", "четырёхтысячном", "пятитысячном", "шеститысячном", "семитысячном", "восьмитысячном", "девятитысячном")
private val SCALES = listOf(
null,
Scale("тысяча", "тысячи", "тысяч"),
Scale("миллион", "миллиона", "миллионов"),
Scale("миллиард", "миллиарда", "миллиардов"),
Scale("триллион", "триллиона", "триллионов"),
Scale("квадриллион", "квадриллиона", "квадриллионов")
)
}
@@ -0,0 +1,62 @@
package com.aletheia.app.ui.reader.tts
import java.util.Locale
internal object RussianSpeechTextSplitter {
fun splitSentences(text: String): List<String> {
val result = mutableListOf<String>()
var start = 0
var index = 0
while (index < text.length) {
val char = text[index]
val isEllipsis = char == '…' || text.startsWith("...", index)
val isTerminal = when {
char == '!' || char == '?' || isEllipsis -> true
char == '.' -> isSentencePeriod(text, index)
else -> false
}
if (!isTerminal) {
index += 1
continue
}
if (text.startsWith("...", index)) index += 2
var end = index + 1
while (end < text.length && text[end] in SENTENCE_CLOSERS) end += 1
val hasSentenceBoundary = end == text.length || text[end].isWhitespace()
if (hasSentenceBoundary && !isFollowedByDialogueDash(text, end)) {
val sentence = text.substring(start, end).trim()
if (sentence.isNotBlank()) result += sentence
start = end
while (start < text.length && text[start].isWhitespace()) start += 1
index = start
} else {
index = end
}
}
val remainder = text.substring(start).trim()
if (remainder.isNotBlank()) result += remainder
return result
}
private fun isFollowedByDialogueDash(text: String, end: Int): Boolean {
var index = end
while (index < text.length && text[index].isWhitespace()) index += 1
return text.getOrNull(index) in DIALOGUE_DASHES
}
private fun isSentencePeriod(text: String, index: Int): Boolean {
if (text.getOrNull(index - 1)?.isDigit() == true && text.getOrNull(index + 1)?.isDigit() == true) return false
val prefix = text.substring(0, index)
val token = prefix.takeLastWhile { it.isLetter() }.lowercase(Locale.forLanguageTag("ru"))
if (token in NON_TERMINAL_ABBREVIATIONS) return false
if (token.length == 1 && prefix.lastOrNull()?.isUpperCase() == true) return false
return true
}
private val SENTENCE_CLOSERS = setOf('"', '\'', '»', '”', ')')
private val DIALOGUE_DASHES = setOf('—', '', '-')
private val NON_TERMINAL_ABBREVIATIONS = setOf(
"г", "гг", "ул", "стр", "рис", "им", "т", "д", "п", "н", "э", "е", "к",
"др", "см", "руб", "коп", "тыс", "млн", "млрд"
)
}
@@ -0,0 +1,708 @@
package com.aletheia.app.ui.reader.tts
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.media.PlaybackParams
import android.util.Log
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.nio.FloatBuffer
import java.nio.LongBuffer
import java.nio.charset.StandardCharsets
import java.util.LinkedHashMap
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import kotlin.coroutines.coroutineContext
class VoskSpeechEngine(context: Context) {
private val appContext = context.applicationContext
private val playbackGeneration = AtomicInteger()
private val trackLock = Any()
private val environment = OrtEnvironment.getEnvironment()
private var synthesisSession: OrtSession? = null
private var bertSession: OrtSession? = null
private var dictionary: PronunciationDictionary? = null
private var tokenizer: WordPieceTokenizer? = null
private var activeTrack: AudioTrack? = null
suspend fun initialize() = withContext(Dispatchers.IO) {
if (synthesisSession != null) return@withContext
val modelFile = copyAssetToInternalStorage(MODEL_ASSET, MODEL_FILE)
val bertFile = copyAssetToInternalStorage(BERT_ASSET, BERT_FILE)
val dictionaryFile = copyAssetToInternalStorage(DICTIONARY_ASSET, DICTIONARY_FILE)
val dictionaryIndex = appContext.assets.open(DICTIONARY_INDEX_ASSET).bufferedReader().useLines { lines ->
lines.mapNotNull { line ->
val separator = line.lastIndexOf('\t')
if (separator <= 0) null else DictionaryIndexEntry(
line.substring(0, separator),
line.substring(separator + 1).toLong()
)
}.toList()
}
val vocabulary = appContext.assets.open(VOCABULARY_ASSET).bufferedReader().useLines { lines ->
lines.mapIndexed { index, token -> token to index.toLong() }.toMap()
}
val options = OrtSession.SessionOptions().apply {
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.NO_OPT)
setIntraOpNumThreads(INFERENCE_THREADS)
setInterOpNumThreads(1)
}
synthesisSession = environment.createSession(modelFile.absolutePath, options)
bertSession = environment.createSession(bertFile.absolutePath, options)
dictionary = PronunciationDictionary(dictionaryFile, dictionaryIndex)
tokenizer = WordPieceTokenizer(vocabulary)
Log.i(
TAG,
"Vosk TTS 0.9 loaded: model=${modelFile.length()} bert=${bertFile.length()} " +
"dictionary=${dictionaryFile.length()}"
)
}
suspend fun speak(
text: String,
rate: Float,
pitch: Float,
pauseScale: Float,
articulationScale: Float
) = withContext(Dispatchers.Default) {
val localSynthesis = checkNotNull(synthesisSession) { "Vosk TTS is not initialized" }
val localBert = checkNotNull(bertSession) { "Vosk BERT is not initialized" }
val localDictionary = checkNotNull(dictionary) { "Vosk dictionary is not initialized" }
val localTokenizer = checkNotNull(tokenizer) { "Vosk tokenizer is not initialized" }
val generation = playbackGeneration.incrementAndGet()
for (chunk in splitText(text)) {
coroutineContext.ensureActive()
check(generation == playbackGeneration.get()) { "Playback stopped" }
val prepared = prepareInput(chunk.text, localDictionary, localTokenizer, localBert)
if (prepared.timeSteps <= 2) continue
Log.d(TAG, "Synthesizing ${chunk.text.length} chars as ${prepared.timeSteps} phonemes")
val output = synthesize(localSynthesis, prepared, articulationScale)
check(output.isNotEmpty()) { "Vosk returned empty audio" }
val playbackSpeed = (rate * BOOK_SPEED_SCALE).coerceIn(MIN_PLAYBACK_SPEED, MAX_PLAYBACK_SPEED)
val scaledPauseMs = if (chunk.trailingPauseMs == NO_TRAILING_PAUSE_MS) {
NO_TRAILING_PAUSE_MS
} else {
(chunk.trailingPauseMs * pauseScale / rate.coerceAtLeast(MIN_RATE_FOR_PAUSE_SCALING))
.toInt()
.coerceAtLeast(MIN_TRAILING_PAUSE_MS)
.coerceAtMost(chunk.maximumPauseMs)
}
Log.d(
TAG,
"Generated ${output.size} samples at $SAMPLE_RATE Hz for ${chunk.text.length} chars; " +
"speaker=$MALE_1_SPEAKER_ID speed=$playbackSpeed pitch=$pitch " +
"articulation=$articulationScale pause=${scaledPauseMs}ms"
)
play(output, generation, playbackSpeed, pitch, scaledPauseMs)
}
}
fun stop() {
playbackGeneration.incrementAndGet()
synchronized(trackLock) {
activeTrack?.runCatching { pause() }
activeTrack?.runCatching { flush() }
activeTrack?.runCatching { stop() }
activeTrack?.release()
activeTrack = null
}
}
fun destroy() {
stop()
dictionary?.close()
dictionary = null
tokenizer = null
bertSession?.close()
bertSession = null
synthesisSession?.close()
synthesisSession = null
}
private fun prepareInput(
text: String,
dictionary: PronunciationDictionary,
tokenizer: WordPieceTokenizer,
bert: OrtSession
): PreparedInput {
val normalized = RussianNumberNormalizer.normalize(text)
.replace(Regex("([А-Яа-яЁё])\\u0301"), "+$1")
.lowercase(Locale.forLanguageTag("ru"))
.replace('—', '-')
.replace('', '-')
.replace("", "...")
.replace(Regex("[«»“”]"), "\"")
.replace(Regex("\\s+"), " ")
.trim()
val encoding = tokenizer.encode(normalized)
val tokenEmbeddings = runBert(bert, encoding.ids)
val selectedEmbeddings = encoding.embeddingPositions.map { position -> tokenEmbeddings[position] }
val phones = buildMultistreamPhones(normalized, dictionary)
val timeSteps = phones.size
val features = LongArray(FEATURE_CHANNELS * timeSteps)
val embeddings = FloatArray(BERT_DIMENSIONS * timeSteps)
phones.forEachIndexed { time, phone ->
phone.features.forEachIndexed { channel, value -> features[channel * timeSteps + time] = value }
val embedding = selectedEmbeddings[phone.bertWordIndex.coerceIn(selectedEmbeddings.indices)]
for (channel in 0 until BERT_DIMENSIONS) {
embeddings[channel * timeSteps + time] = embedding[channel]
}
}
return PreparedInput(features, embeddings, timeSteps)
}
private fun runBert(session: OrtSession, ids: LongArray): Array<FloatArray> {
val shape = longArrayOf(1, ids.size.toLong())
val inputIds = OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape)
val attentionMask = OnnxTensor.createTensor(
environment,
LongBuffer.wrap(LongArray(ids.size) { 1L }),
shape
)
val tokenTypes = OnnxTensor.createTensor(environment, LongBuffer.wrap(LongArray(ids.size)), shape)
try {
session.run(
mapOf(
"input_ids" to inputIds,
"attention_mask" to attentionMask,
"token_type_ids" to tokenTypes
)
).use { result ->
@Suppress("UNCHECKED_CAST")
return result[0].value as Array<FloatArray>
}
} finally {
inputIds.close()
attentionMask.close()
tokenTypes.close()
}
}
private fun synthesize(session: OrtSession, prepared: PreparedInput, articulationScale: Float): FloatArray {
val input = OnnxTensor.createTensor(
environment,
LongBuffer.wrap(prepared.features),
longArrayOf(1, FEATURE_CHANNELS.toLong(), prepared.timeSteps.toLong())
)
val inputLengths = OnnxTensor.createTensor(
environment,
LongBuffer.wrap(longArrayOf(prepared.timeSteps.toLong())),
longArrayOf(1)
)
val scales = OnnxTensor.createTensor(
environment,
FloatBuffer.wrap(
floatArrayOf(
NOISE_SCALE,
articulationScale.coerceIn(MIN_ARTICULATION_SCALE, MAX_ARTICULATION_SCALE),
DURATION_NOISE_SCALE
)
),
longArrayOf(3)
)
val speaker = OnnxTensor.createTensor(
environment,
LongBuffer.wrap(longArrayOf(MALE_1_SPEAKER_ID.toLong())),
longArrayOf(1)
)
val bert = OnnxTensor.createTensor(
environment,
FloatBuffer.wrap(prepared.bertEmbeddings),
longArrayOf(1, BERT_DIMENSIONS.toLong(), prepared.timeSteps.toLong())
)
try {
session.run(
mapOf(
"input" to input,
"input_lengths" to inputLengths,
"scales" to scales,
"sid" to speaker,
"bert" to bert
)
).use { result ->
return flattenFloatOutput(result[0].value)
}
} finally {
input.close()
inputLengths.close()
scales.close()
speaker.close()
bert.close()
}
}
private fun buildMultistreamPhones(
text: String,
dictionary: PronunciationDictionary
): List<PhoneFeatures> {
val rawPhones = mutableListOf(RawPhone("^", emptyList(), 0, 0))
val word = StringBuilder()
val pendingPunctuation = mutableListOf<String>()
var inQuote = 0
var bertWordIndex = 1
fun flushWord() {
if (word.isEmpty()) return
val value = word.toString()
val pronunciation = dictionary.find(value) ?: RussianG2p.convert(value)
pronunciation.split(' ').filter(String::isNotBlank).forEach { phone ->
rawPhones += RawPhone(phone, emptyList(), inQuote, bertWordIndex)
}
word.clear()
bertWordIndex += 1
}
fun appendSpace() {
rawPhones += RawPhone(" ", pendingPunctuation.toList(), inQuote, bertWordIndex)
pendingPunctuation.clear()
}
var index = 0
while (index < text.length) {
if (text.startsWith("...", index)) {
flushWord()
pendingPunctuation += "..."
index += 3
continue
}
val char = text[index]
when {
char == '"' || char == '«' || char == '»' || char == '“' || char == '”' -> {
flushWord()
inQuote = 1 - inQuote
}
char.isWhitespace() -> {
flushWord()
appendSpace()
}
char == '-' && text.getOrNull(index - 1)?.isLetterOrDigit() == true &&
text.getOrNull(index + 1)?.isLetterOrDigit() == true -> {
// A lexical hyphen separates WordPiece words but must not create an acoustic pause.
flushWord()
}
char in MULTISTREAM_PUNCTUATION -> {
flushWord()
pendingPunctuation += char.toString()
}
char.isLetterOrDigit() || char == '+' -> word.append(char)
}
index += 1
}
flushWord()
appendSpace()
rawPhones += RawPhone("$", emptyList(), 0, bertWordIndex)
var lastPunctuation = " "
var lastSentencePunctuation = " "
val reversed = mutableListOf<PhoneFeatures>()
for (phone in rawPhones.asReversed()) {
lastSentencePunctuation = when {
"..." in phone.punctuation -> "..."
"." in phone.punctuation -> "."
"!" in phone.punctuation -> "!"
"?" in phone.punctuation -> "?"
"-" in phone.punctuation -> "-"
else -> lastSentencePunctuation
}
if (phone.punctuation.isNotEmpty()) lastPunctuation = phone.punctuation.first()
val currentPunctuation = phone.punctuation.firstOrNull() ?: "_"
val values = longArrayOf(
PHONEME_IDS.getValue(phone.phone),
PHONEME_IDS.getValue(currentPunctuation),
phone.inQuote.toLong(),
PHONEME_IDS.getValue(lastPunctuation),
PHONEME_IDS.getValue(lastSentencePunctuation)
)
reversed += PhoneFeatures(values, phone.bertWordIndex)
}
return reversed.asReversed()
}
private fun flattenFloatOutput(value: Any?): FloatArray = when (value) {
is FloatArray -> value
is Array<*> -> value.asSequence().flatMap { flattenFloatOutput(it).asSequence() }.toList().toFloatArray()
else -> error("Unexpected Vosk output type: ${value?.javaClass?.name}")
}
private fun play(
samples: FloatArray,
generation: Int,
playbackSpeed: Float,
pitch: Float,
trailingPauseMs: Int
) {
val minBytes = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_FLOAT
).coerceAtLeast(4096)
val track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
)
.setBufferSizeInBytes(minBytes)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
track.playbackParams = PlaybackParams()
.allowDefaults()
.setSpeed(playbackSpeed)
.setPitch(pitch.coerceIn(MIN_PLAYBACK_PITCH, MAX_PLAYBACK_PITCH))
synchronized(trackLock) { activeTrack = track }
try {
track.play()
var offset = 0
while (offset < samples.size && generation == playbackGeneration.get()) {
val written = track.write(samples, offset, samples.size - offset, AudioTrack.WRITE_BLOCKING)
if (written <= 0) error("AudioTrack write failed: $written")
offset += written
}
var silenceSamples = trailingPauseMs * SAMPLE_RATE / 1_000
while (silenceSamples > 0 && generation == playbackGeneration.get()) {
val count = minOf(silenceSamples, SILENCE_BUFFER.size)
val written = track.write(SILENCE_BUFFER, 0, count, AudioTrack.WRITE_BLOCKING)
if (written <= 0) error("AudioTrack silence write failed: $written")
silenceSamples -= written
}
check(generation == playbackGeneration.get()) { "Playback stopped" }
} finally {
synchronized(trackLock) { if (activeTrack === track) activeTrack = null }
track.runCatching { stop() }
track.release()
}
}
private fun splitText(text: String): List<SpeechChunk> {
val normalized = text
.replace(Regex("\\[\\d+(?:[,-]\\d+)*\\]"), " ")
.replace("\r\n", "\n")
.replace('\r', '\n')
.replace(Regex("[\\t\\x0B\\f ]+"), " ")
.replace(Regex(" *\\n+ *"), "\n")
.trim()
if (normalized.isBlank()) return emptyList()
val chunks = mutableListOf<SpeechChunk>()
for (paragraph in normalized.split('\n').filter(String::isNotBlank)) {
for (sentence in RussianSpeechTextSplitter.splitSentences(paragraph)) {
for (unit in splitControlledPauses(sentence)) {
val parts = splitAtWordBoundaries(unit.text)
parts.forEachIndexed { index, part ->
val isLastPart = index == parts.lastIndex
chunks += SpeechChunk(
text = part.trim(),
trailingPauseMs = if (isLastPart) unit.trailingPauseMs else NO_TRAILING_PAUSE_MS,
maximumPauseMs = if (isLastPart) unit.maximumPauseMs else NO_TRAILING_PAUSE_MS
)
}
}
}
}
return chunks.filter { it.text.isNotBlank() }
}
private fun splitControlledPauses(text: String): List<ProsodicUnit> {
val units = mutableListOf<ProsodicUnit>()
val current = StringBuilder()
fun flushControlledPause() {
val value = current.toString().trim()
if (value.isNotBlank()) {
units += ProsodicUnit(value, CONTROLLED_PUNCTUATION_PAUSE_MS, CONTROLLED_PUNCTUATION_MAX_PAUSE_MS)
}
current.clear()
}
text.forEachIndexed { index, char ->
val isWordInternalHyphen = char == '-' &&
text.getOrNull(index - 1)?.isLetterOrDigit() == true &&
text.getOrNull(index + 1)?.isLetterOrDigit() == true
when {
char in CONTROLLED_PAUSE_PUNCTUATION && !isWordInternalHyphen -> flushControlledPause()
else -> current.append(char)
}
}
val remainder = current.toString().trim()
if (remainder.isNotBlank()) units += ProsodicUnit(remainder, pauseAfter(remainder), Int.MAX_VALUE)
return units
}
private fun splitAtWordBoundaries(text: String): List<String> {
if (text.length <= MAX_CHUNK_CHARS) return listOf(text)
val parts = mutableListOf<String>()
var remaining = text.trim()
while (remaining.length > MAX_CHUNK_CHARS) {
val boundary = remaining.lastIndexOf(' ', startIndex = MAX_CHUNK_CHARS)
.takeIf { it > MIN_CHUNK_CHARS }
?: remaining.indexOf(' ', startIndex = MAX_CHUNK_CHARS).takeIf { it >= 0 }
?: remaining.length
parts += remaining.substring(0, boundary).trim()
remaining = remaining.substring(boundary).trimStart()
}
if (remaining.isNotBlank()) parts += remaining
return parts
}
private fun pauseAfter(text: String): Int = when (text.trimEnd().lastOrNull()) {
'…' -> ELLIPSIS_PAUSE_MS
'?' -> QUESTION_PAUSE_MS
'!' -> EXCLAMATION_PAUSE_MS
'.' -> SENTENCE_PAUSE_MS
':', ';' -> CLAUSE_PAUSE_MS
else -> NO_TRAILING_PAUSE_MS
}
private fun copyAssetToInternalStorage(assetPath: String, fileName: String): File {
val destination = File(appContext.filesDir, "tts/vosk_0_9/$fileName")
val expectedSize = appContext.assets.open(assetPath).use { input ->
var total = 0L
val buffer = ByteArray(COPY_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
total += read
}
total
}
if (destination.isFile && destination.length() == expectedSize) return destination
destination.parentFile?.mkdirs()
val temporary = File(destination.parentFile, "$fileName.tmp")
appContext.assets.open(assetPath).use { input ->
FileOutputStream(temporary).use { output -> input.copyTo(output, COPY_BUFFER_SIZE) }
}
check(temporary.length() == expectedSize) { "Vosk asset copy is incomplete: $fileName" }
if (destination.exists()) check(destination.delete()) { "Cannot replace old Vosk asset: $fileName" }
check(temporary.renameTo(destination)) { "Cannot install Vosk asset: $fileName" }
return destination
}
private class WordPieceTokenizer(private val vocabulary: Map<String, Long>) {
fun encode(text: String): BertEncoding {
val ids = mutableListOf(vocabulary.getValue("[CLS]"))
val embeddingPositions = mutableListOf(0)
for (token in basicTokens(text)) {
val punctuation = token in BERT_EXCLUDED_PUNCTUATION
val pieces = if (punctuation) {
listOf(vocabulary[token] ?: vocabulary.getValue("[UNK]"))
} else {
wordPieces(token)
}
if (!punctuation) embeddingPositions += ids.size
ids += pieces
}
ids += vocabulary.getValue("[SEP]")
embeddingPositions += ids.lastIndex
return BertEncoding(ids.toLongArray(), embeddingPositions.toIntArray())
}
private fun basicTokens(text: String): List<String> {
val result = mutableListOf<String>()
val word = StringBuilder()
fun flush() {
if (word.isNotEmpty()) result += word.toString().also { word.clear() }
}
text.forEach { char ->
when {
char.isWhitespace() -> flush()
char.isLetterOrDigit() -> word.append(char.lowercaseChar())
char == '+' -> Unit // Explicit stress marker belongs to G2P, not to BERT tokenization.
else -> {
flush()
result += char.toString()
}
}
}
flush()
return result
}
private fun wordPieces(token: String): List<Long> {
if (token.length > MAX_WORD_PIECE_CHARS) return listOf(vocabulary.getValue("[UNK]"))
val result = mutableListOf<Long>()
var start = 0
while (start < token.length) {
var end = token.length
var found: Long? = null
while (start < end) {
val piece = token.substring(start, end).let { if (start == 0) it else "##$it" }
found = vocabulary[piece]
if (found != null) break
end -= 1
}
if (found == null) return listOf(vocabulary.getValue("[UNK]"))
result += found
start = end
}
return result
}
}
private data class DictionaryIndexEntry(val word: String, val offset: Long)
private class PronunciationDictionary(
file: File,
private val index: List<DictionaryIndexEntry>
) : AutoCloseable {
private val source = RandomAccessFile(file, "r")
private val cache = object : LinkedHashMap<String, String>(CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean = size > CACHE_SIZE
}
@Synchronized
fun find(word: String): String? {
cache[word]?.let { return it }
var low = 0
var high = index.lastIndex
while (low <= high) {
val midpoint = (low + high) ushr 1
if (index[midpoint].word <= word) low = midpoint + 1 else high = midpoint - 1
}
val rangeIndex = high.coerceAtLeast(0)
val rangeEnd = index.getOrNull(rangeIndex + 1)?.offset ?: source.length()
source.seek(index[rangeIndex].offset)
while (source.filePointer < rangeEnd) {
val line = readUtf8Line(source) ?: break
val entryWord = line.substringBefore('\t')
when {
entryWord < word -> continue
entryWord > word -> return null
else -> {
val pronunciation = line.substringAfter('\t', "").trim()
if (pronunciation.isNotEmpty()) cache[word] = pronunciation
return pronunciation.takeIf(String::isNotEmpty)
}
}
}
return null
}
override fun close() = source.close()
private fun readUtf8Line(file: RandomAccessFile): String? {
val bytes = ByteArrayOutputStream(64)
while (true) {
val value = file.read()
if (value < 0) return if (bytes.size() == 0) null else bytes.toString(StandardCharsets.UTF_8.name())
if (value == '\n'.code) break
if (value != '\r'.code) bytes.write(value)
}
return bytes.toString(StandardCharsets.UTF_8.name())
}
}
private object RussianG2p {
private val softLetters = setOf('я', 'ё', 'ю', 'и', 'ь', 'е')
private val syllableStarts = setOf('#', 'ъ', 'ь', 'а', 'я', 'о', 'ё', 'у', 'ю', 'э', 'е', 'и', 'ы', '-')
private val softHardConsonants = mapOf(
'б' to "b", 'в' to "v", 'г' to "g", 'д' to "d", 'з' to "z", 'к' to "k",
'л' to "l", 'м' to "m", 'н' to "n", 'п' to "p", 'р' to "r", 'с' to "s",
'т' to "t", 'ф' to "f", 'х' to "h"
)
private val otherConsonants = mapOf('ж' to "zh", 'ц' to "c", 'ч' to "ch", 'ш' to "sh", 'щ' to "sch", 'й' to "j")
private val vowels = mapOf('а' to "a", 'я' to "a", 'у' to "u", 'ю' to "u", 'о' to "o", 'ё' to "o", 'э' to "e", 'е' to "e", 'и' to "i", 'ы' to "y")
fun convert(word: String): String {
val marked = mutableListOf<Pair<Char, Int>>()
var stress = 0
for (char in "#$word#") {
if (char == '+') stress = 1 else {
marked += char to stress
stress = 0
}
}
val phones = mutableListOf<String>()
for (index in marked.indices) {
val (char, accent) = marked[index]
val next = marked.getOrNull(index + 1)?.first
when {
char in softHardConsonants -> phones += softHardConsonants.getValue(char) + if (next in softLetters) "j" else ""
char in otherConsonants -> phones += otherConsonants.getValue(char)
char in vowels -> {
val previous = marked.getOrNull(index - 1)?.first ?: '#'
if (previous in syllableStarts && char in setOf('я', 'ю', 'е', 'ё')) phones += "j"
phones += vowels.getValue(char) + accent
}
}
}
return phones.joinToString(" ")
}
}
private companion object {
data class PreparedInput(
val features: LongArray,
val bertEmbeddings: FloatArray,
val timeSteps: Int
)
data class BertEncoding(val ids: LongArray, val embeddingPositions: IntArray)
data class RawPhone(val phone: String, val punctuation: List<String>, val inQuote: Int, val bertWordIndex: Int)
data class PhoneFeatures(val features: LongArray, val bertWordIndex: Int)
data class SpeechChunk(val text: String, val trailingPauseMs: Int, val maximumPauseMs: Int = Int.MAX_VALUE)
data class ProsodicUnit(val text: String, val trailingPauseMs: Int, val maximumPauseMs: Int)
const val TAG = "AletheiaVoskTts"
const val MODEL_FILE = "model.onnx"
const val BERT_FILE = "bert.int8.onnx"
const val DICTIONARY_FILE = "dict.tsv"
const val MODEL_ASSET = "tts/vosk_0_9/$MODEL_FILE"
const val BERT_ASSET = "tts/vosk_0_9/$BERT_FILE"
const val DICTIONARY_ASSET = "tts/vosk_0_9/$DICTIONARY_FILE"
const val DICTIONARY_INDEX_ASSET = "tts/vosk_0_9/dictionary.index"
const val VOCABULARY_ASSET = "tts/vosk_0_9/vocab.txt"
const val SAMPLE_RATE = 22_050
const val MALE_1_SPEAKER_ID = 4
const val FEATURE_CHANNELS = 5
const val BERT_DIMENSIONS = 768
const val INFERENCE_THREADS = 4
const val COPY_BUFFER_SIZE = 1024 * 1024
const val CACHE_SIZE = 4_096
const val MAX_WORD_PIECE_CHARS = 100
const val MAX_CHUNK_CHARS = 120
const val MIN_CHUNK_CHARS = 80
const val BOOK_SPEED_SCALE = 0.82f
const val MIN_RATE_FOR_PAUSE_SCALING = 0.10f
const val MIN_PLAYBACK_SPEED = 0.50f
const val MAX_PLAYBACK_SPEED = 1.50f
const val MIN_PLAYBACK_PITCH = 0.75f
const val MAX_PLAYBACK_PITCH = 1.30f
const val MIN_ARTICULATION_SCALE = 0.90f
const val MAX_ARTICULATION_SCALE = 1.20f
const val NOISE_SCALE = 0.8f
const val DURATION_NOISE_SCALE = 0.8f
const val NO_TRAILING_PAUSE_MS = 0
const val MIN_TRAILING_PAUSE_MS = 80
const val CLAUSE_PAUSE_MS = 320
const val CONTROLLED_PUNCTUATION_PAUSE_MS = 300
const val CONTROLLED_PUNCTUATION_MAX_PAUSE_MS = 300
const val SENTENCE_PAUSE_MS = 600
const val EXCLAMATION_PAUSE_MS = 600
const val QUESTION_PAUSE_MS = 600
const val ELLIPSIS_PAUSE_MS = 1_200
val SILENCE_BUFFER = FloatArray(SAMPLE_RATE / 10)
val CONTROLLED_PAUSE_PUNCTUATION = setOf('—', '', '-', '(', ')', ';', ':')
val MULTISTREAM_PUNCTUATION = setOf('!', '(', ')', ',', '-', '.', ':', ';', '?')
val BERT_EXCLUDED_PUNCTUATION = setOf("-", ",", ".", "?", "!", ";", ":", "\"")
val PHONEME_IDS = listOf(
"_", "^", "$", " ", "!", "'", "(", ")", ",", "-", ".", "...", ":", ";", "?",
"a0", "a1", "b", "bj", "c", "ch", "d", "dj", "e0", "e1", "f", "fj", "g", "gj",
"h", "hj", "i0", "i1", "j", "k", "kj", "l", "lj", "m", "mj", "n", "nj", "o0",
"o1", "p", "pj", "r", "rj", "s", "sch", "sh", "sj", "t", "tj", "u0", "u1", "v",
"vj", "y0", "y1", "z", "zh", "zj"
).mapIndexed { index, phoneme -> phoneme to index.toLong() }.toMap()
}
}
@@ -0,0 +1,40 @@
package com.aletheia.app.ui.reader.tts
import org.junit.Assert.assertEquals
import org.junit.Test
class RussianNumberNormalizerTest {
@Test
fun `normalizes cardinal integers`() {
assertEquals("ноль один двадцать один", RussianNumberNormalizer.normalize("0 1 21"))
assertEquals("одна тысяча один", RussianNumberNormalizer.normalize("1001"))
assertEquals("две тысячи двадцать шесть", RussianNumberNormalizer.normalize("2026"))
assertEquals("двадцать две тысячи", RussianNumberNormalizer.normalize("22000"))
}
@Test
fun `preserves leading zeroes and reads decimal digits`() {
assertEquals("ноль ноль семь", RussianNumberNormalizer.normalize("007"))
assertEquals("минус двенадцать запятая ноль пять", RussianNumberNormalizer.normalize("-12,05"))
}
@Test
fun `normalizes number sign and non breaking groups`() {
assertEquals("номер пять", RussianNumberNormalizer.normalize("№ 5"))
assertEquals("двенадцать тысяч триста сорок пять", RussianNumberNormalizer.normalize("12\u00A0345"))
}
@Test
fun `does not rewrite digits inside words`() {
assertEquals("глава2 и abc123", RussianNumberNormalizer.normalize("глава2 и abc123"))
}
@Test
fun `inflects four digit years before году`() {
assertEquals(
"В две тысячи двадцать шестом году и в тысяча восемьсот девяносто седьмом году",
RussianNumberNormalizer.normalize("В 2026 году и в 1897 году")
)
assertEquals("В двухтысячном году", RussianNumberNormalizer.normalize("В 2000 году"))
}
}
@@ -0,0 +1,27 @@
package com.aletheia.app.ui.reader.tts
import org.junit.Assert.assertEquals
import org.junit.Test
class RussianSpeechTextSplitterTest {
@Test
fun keepsTerminalPunctuationAndDialogueDashInOneSpeechSentence() {
listOf(
"Стой. — сказал он.",
"Стой! — сказал он.",
"Стой? — спросил он.",
"«Стой!» — сказал он.",
"Стой? - спросил он."
).forEach { text ->
assertEquals(listOf(text), RussianSpeechTextSplitter.splitSentences(text))
}
}
@Test
fun stillSeparatesOrdinarySentences() {
assertEquals(
listOf("Первое предложение.", "Второе предложение!", "Третье?"),
RussianSpeechTextSplitter.splitSentences("Первое предложение. Второе предложение! Третье?")
)
}
}