feat: add expressive offline Russian book TTS
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -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.** { *; }
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
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("Первое предложение. Второе предложение! Третье?")
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user