feat: add remote audiobook generation
This commit is contained in:
+23
-7
@@ -1,7 +1,15 @@
|
||||
plugins {
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
val audiobookApiTokenFile = file("${System.getProperty("user.home")}/.aletheia/audiobook-api-token.txt")
|
||||
val audiobookApiToken = if (audiobookApiTokenFile.isFile) {
|
||||
audiobookApiTokenFile.readText().trim()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
fun quotedBuildConfig(value: String): String =
|
||||
"\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
||||
|
||||
android {
|
||||
namespace = "com.aletheia.app"
|
||||
@@ -11,8 +19,15 @@ android {
|
||||
applicationId = "com.aletheia.app"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 45
|
||||
versionName = "2.34"
|
||||
versionCode = 48
|
||||
versionName = "2.37"
|
||||
|
||||
buildConfigField(
|
||||
"String",
|
||||
"AUDIOBOOK_API_BASE_URL",
|
||||
quotedBuildConfig("https://argus.kusoft.xyz/aletheia-tts/")
|
||||
)
|
||||
buildConfigField("String", "AUDIOBOOK_API_TOKEN", quotedBuildConfig(audiobookApiToken))
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -49,6 +64,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
@@ -57,9 +73,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
androidResources {
|
||||
noCompress += "onnx"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -74,7 +87,10 @@ 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")
|
||||
testImplementation("org.json:json:20240303")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test:runner:1.6.2")
|
||||
}
|
||||
|
||||
|
||||
Vendored
-4
@@ -5,7 +5,3 @@
|
||||
|
||||
# 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.** { *; }
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<application
|
||||
android:name=".AletheiaApplication"
|
||||
@@ -25,12 +28,25 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".audiobook.AudioBookGenerationService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="User-requested audiobook upload, remote progress monitoring, and M4A download" />
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".ui.reader.ReaderActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Aletheia"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.audiobook.AudioBookPlayerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Aletheia" />
|
||||
<activity
|
||||
android:name=".ui.qbooks.BookDetailActivity"
|
||||
android:exported="false"
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
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
@@ -2,6 +2,7 @@ package com.aletheia.app
|
||||
|
||||
import android.app.Application
|
||||
import com.aletheia.app.data.AppDatabaseHelper
|
||||
import com.aletheia.app.data.AudioBookRepository
|
||||
import com.aletheia.app.data.BookParserService
|
||||
import com.aletheia.app.data.BookRepository
|
||||
import com.aletheia.app.data.CatalogCache
|
||||
@@ -35,6 +36,9 @@ class AletheiaApplication : Application() {
|
||||
lateinit var bookRepository: BookRepository
|
||||
private set
|
||||
|
||||
lateinit var audioBookRepository: AudioBookRepository
|
||||
private set
|
||||
|
||||
lateinit var qBooksService: QBooksService
|
||||
private set
|
||||
|
||||
@@ -53,6 +57,7 @@ class AletheiaApplication : Application() {
|
||||
settingsRepository = SettingsRepository(this, databaseHelper)
|
||||
bookParserService = BookParserService(this)
|
||||
bookRepository = BookRepository(databaseHelper, bookParserService, settingsRepository)
|
||||
audioBookRepository = AudioBookRepository(this, databaseHelper)
|
||||
catalogCache = CatalogCache(this)
|
||||
qBooksService = QBooksService(settingsRepository, catalogCache)
|
||||
appUpdateManager = ArgusUpdateManager(this)
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package com.aletheia.app.audiobook
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.aletheia.app.AletheiaApplication
|
||||
import com.aletheia.app.R
|
||||
import com.aletheia.app.model.AudioBook
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AudioBookGenerationService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val jobs = ConcurrentHashMap<Long, Job>()
|
||||
private val userCancellations = ConcurrentHashMap.newKeySet<Long>()
|
||||
private val app by lazy { application as AletheiaApplication }
|
||||
private val client by lazy { RemoteAudioBookClient() }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val audioBookId = intent?.getLongExtra(EXTRA_AUDIOBOOK_ID, 0L) ?: 0L
|
||||
when (intent?.action) {
|
||||
ACTION_CANCEL -> cancelGeneration(audioBookId)
|
||||
ACTION_START -> if (audioBookId > 0 && jobs[audioBookId]?.isActive != true) {
|
||||
startInForeground(audioBookId, 0, "Подключение к серверу…")
|
||||
jobs[audioBookId] = scope.launch {
|
||||
try {
|
||||
generate(audioBookId)
|
||||
} finally {
|
||||
jobs.remove(audioBookId)
|
||||
if (jobs.isEmpty()) stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private suspend fun generate(audioBookId: Long) {
|
||||
var record = app.audioBookRepository.getById(audioBookId) ?: return
|
||||
val book = app.bookRepository.getBookById(record.sourceBookId)
|
||||
?: return fail(record, "Исходная книга не найдена")
|
||||
val source = File(book.filePath)
|
||||
val partial = app.audioBookRepository.partialOutputFile(audioBookId)
|
||||
val output = app.audioBookRepository.outputFile(audioBookId)
|
||||
try {
|
||||
var remoteJobId = record.remoteJobId
|
||||
if (remoteJobId.isNullOrBlank()) {
|
||||
record = saveProgress(
|
||||
record.copy(
|
||||
status = AudioBook.Status.GENERATING,
|
||||
processedCharacters = 0,
|
||||
totalCharacters = 0,
|
||||
currentChapter = "Отправка книги на CMP-сервер…",
|
||||
errorMessage = null
|
||||
)
|
||||
)
|
||||
val created = client.create(source, book.title, book.author)
|
||||
remoteJobId = created.id
|
||||
record = saveProgress(record.copy(remoteJobId = remoteJobId))
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val remote = try {
|
||||
client.status(remoteJobId)
|
||||
} catch (error: IOException) {
|
||||
record = saveProgress(
|
||||
record.copy(currentChapter = "Нет связи с сервером, повтор через 15 секунд…")
|
||||
)
|
||||
delay(RETRY_DELAY_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
record = saveProgress(
|
||||
record.copy(
|
||||
status = AudioBook.Status.GENERATING,
|
||||
processedCharacters = remote.processedCharacters,
|
||||
totalCharacters = remote.totalCharacters,
|
||||
durationMs = remote.durationMs,
|
||||
chaptersJson = remote.chaptersJson,
|
||||
currentChapter = remote.currentChapter ?: stageLabel(remote.stage),
|
||||
errorMessage = null
|
||||
)
|
||||
)
|
||||
startInForeground(audioBookId, record.progressPercent, record.currentChapter.orEmpty())
|
||||
|
||||
when (remote.status) {
|
||||
"ready" -> break
|
||||
"failed" -> throw IOException(remote.error ?: "CMP-сервер не смог создать аудиокнигу")
|
||||
"cancelled" -> {
|
||||
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
|
||||
return
|
||||
}
|
||||
}
|
||||
delay(POLL_DELAY_MS)
|
||||
}
|
||||
|
||||
partial.delete()
|
||||
record = saveProgress(record.copy(currentChapter = "Скачивание готового M4A…"))
|
||||
client.download(remoteJobId, partial)
|
||||
output.delete()
|
||||
check(partial.renameTo(output)) { "Не удалось сохранить готовый M4A-файл" }
|
||||
record = saveProgress(
|
||||
record.copy(
|
||||
filePath = output.absolutePath,
|
||||
remoteJobId = null,
|
||||
status = AudioBook.Status.READY,
|
||||
processedCharacters = record.totalCharacters,
|
||||
currentChapter = null
|
||||
)
|
||||
)
|
||||
runCatching { client.cancelOrDelete(remoteJobId) }
|
||||
.onFailure { Log.w(TAG, "Remote job cleanup failed for id=$remoteJobId", it) }
|
||||
notifyComplete(record)
|
||||
} catch (cancelled: CancellationException) {
|
||||
if (userCancellations.remove(audioBookId)) {
|
||||
record.remoteJobId?.let { runCatching { client.cancelOrDelete(it) } }
|
||||
partial.delete()
|
||||
if (app.audioBookRepository.getById(audioBookId) != null) {
|
||||
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
|
||||
}
|
||||
}
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
partial.delete()
|
||||
Log.e(TAG, "Audiobook generation failed for id=$audioBookId", error)
|
||||
fail(record, error.diagnosticMessage())
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelGeneration(audioBookId: Long) {
|
||||
if (audioBookId <= 0) return
|
||||
userCancellations.add(audioBookId)
|
||||
val active = jobs[audioBookId]
|
||||
if (active != null) {
|
||||
active.cancel()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
val record = app.audioBookRepository.getById(audioBookId) ?: return@launch
|
||||
record.remoteJobId?.let { runCatching { client.cancelOrDelete(it) } }
|
||||
app.audioBookRepository.partialOutputFile(audioBookId).delete()
|
||||
if (app.audioBookRepository.getById(audioBookId) != null) {
|
||||
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
|
||||
}
|
||||
userCancellations.remove(audioBookId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveProgress(audioBook: AudioBook): AudioBook =
|
||||
app.audioBookRepository.save(audioBook).also(::broadcastProgress)
|
||||
|
||||
private fun stageLabel(stage: String): String = when (stage) {
|
||||
"queued" -> "В очереди на CMP-сервере…"
|
||||
"extracting" -> "Подготовка текста книги…"
|
||||
"loading_model" -> "Загрузка мужского голоса Qwen…"
|
||||
"synthesizing" -> "Создание аудиокниги…"
|
||||
"encoding" -> "Сборка итогового M4A…"
|
||||
"ready" -> "Аудиокнига готова"
|
||||
else -> "Обработка на CMP-сервере…"
|
||||
}
|
||||
|
||||
private fun Throwable.diagnosticMessage(): String =
|
||||
generateSequence(this) { it.cause }.take(4).joinToString(" → ") { cause ->
|
||||
cause.message?.takeIf(String::isNotBlank) ?: cause.javaClass.simpleName
|
||||
}
|
||||
|
||||
private fun fail(record: AudioBook, message: String) {
|
||||
val failed = saveProgress(
|
||||
record.copy(status = AudioBook.Status.FAILED, currentChapter = null, errorMessage = message)
|
||||
)
|
||||
notificationManager.notify(
|
||||
notificationId(failed.id),
|
||||
notification(failed.id, failed.progressPercent, "Ошибка: $message", ongoing = false)
|
||||
)
|
||||
}
|
||||
|
||||
private fun broadcastProgress(audioBook: AudioBook) {
|
||||
sendBroadcast(
|
||||
Intent(ACTION_PROGRESS)
|
||||
.setPackage(packageName)
|
||||
.putExtra(EXTRA_AUDIOBOOK_ID, audioBook.id)
|
||||
)
|
||||
}
|
||||
|
||||
private fun startInForeground(id: Long, progress: Int, detail: String) {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
notificationId(id),
|
||||
notification(id, progress, detail, ongoing = true),
|
||||
if (Build.VERSION.SDK_INT >= 34) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun notifyComplete(audioBook: AudioBook) {
|
||||
notificationManager.notify(
|
||||
notificationId(audioBook.id),
|
||||
notification(audioBook.id, 100, "Аудиокнига готова", ongoing = false)
|
||||
)
|
||||
}
|
||||
|
||||
private fun notification(id: Long, progress: Int, detail: String, ongoing: Boolean) =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_reader_v2_voice)
|
||||
.setContentTitle("Создание аудиокниги · Qwen, мужской голос")
|
||||
.setContentText(detail)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOngoing(ongoing)
|
||||
.setProgress(100, progress, ongoing && progress <= 0)
|
||||
.apply {
|
||||
if (ongoing) {
|
||||
addAction(
|
||||
0,
|
||||
"Отменить",
|
||||
PendingIntent.getService(
|
||||
this@AudioBookGenerationService,
|
||||
id.toInt(),
|
||||
Intent(this@AudioBookGenerationService, AudioBookGenerationService::class.java)
|
||||
.setAction(ACTION_CANCEL)
|
||||
.putExtra(EXTRA_AUDIOBOOK_ID, id),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Создание аудиокниг", NotificationManager.IMPORTANCE_LOW)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val notificationManager get() = getSystemService(NotificationManager::class.java)
|
||||
private fun notificationId(id: Long) = NOTIFICATION_BASE + (id % 10_000).toInt()
|
||||
|
||||
companion object {
|
||||
const val ACTION_PROGRESS = "com.aletheia.app.audiobook.PROGRESS"
|
||||
private const val ACTION_START = "com.aletheia.app.audiobook.START"
|
||||
private const val ACTION_CANCEL = "com.aletheia.app.audiobook.CANCEL"
|
||||
const val EXTRA_AUDIOBOOK_ID = "audio_book_id"
|
||||
private const val CHANNEL_ID = "audiobook_generation"
|
||||
private const val NOTIFICATION_BASE = 52_000
|
||||
private const val TAG = "AudioBookGeneration"
|
||||
private const val POLL_DELAY_MS = 3_000L
|
||||
private const val RETRY_DELAY_MS = 15_000L
|
||||
|
||||
fun start(context: Context, audioBookId: Long) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
Intent(context, AudioBookGenerationService::class.java)
|
||||
.setAction(ACTION_START)
|
||||
.putExtra(EXTRA_AUDIOBOOK_ID, audioBookId)
|
||||
)
|
||||
}
|
||||
|
||||
fun cancel(context: Context, audioBookId: Long) {
|
||||
context.startService(
|
||||
Intent(context, AudioBookGenerationService::class.java)
|
||||
.setAction(ACTION_CANCEL)
|
||||
.putExtra(EXTRA_AUDIOBOOK_ID, audioBookId)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.aletheia.app.audiobook
|
||||
|
||||
import com.aletheia.app.BuildConfig
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
|
||||
class RemoteAudioBookClient(
|
||||
private val baseUrl: String = BuildConfig.AUDIOBOOK_API_BASE_URL,
|
||||
private val token: String = BuildConfig.AUDIOBOOK_API_TOKEN
|
||||
) {
|
||||
fun create(source: File, title: String, author: String): RemoteJob {
|
||||
require(source.isFile) { "Файл исходной книги не найден" }
|
||||
val boundary = "aletheia-${UUID.randomUUID()}"
|
||||
val connection = open("v1/audiobooks").apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
setChunkedStreamingMode(256 * 1024)
|
||||
setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||
}
|
||||
try {
|
||||
BufferedOutputStream(connection.outputStream, 256 * 1024).use { output ->
|
||||
output.writeTextPart(boundary, "title", title)
|
||||
output.writeTextPart(boundary, "author", author)
|
||||
val safeName = source.name.replace('"', '_')
|
||||
output.writeAscii("--$boundary\r\n")
|
||||
output.writeAscii("Content-Disposition: form-data; name=\"book\"; filename=\"$safeName\"\r\n")
|
||||
output.writeAscii("Content-Type: ${contentType(source)}\r\n\r\n")
|
||||
source.inputStream().buffered(256 * 1024).use { it.copyTo(output, 256 * 1024) }
|
||||
output.writeAscii("\r\n--$boundary--\r\n")
|
||||
}
|
||||
return parseJob(connection.readSuccessfulBody())
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun status(jobId: String): RemoteJob {
|
||||
val connection = open("v1/audiobooks/$jobId")
|
||||
return try {
|
||||
parseJob(connection.readSuccessfulBody())
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun download(jobId: String, destination: File) {
|
||||
val connection = open("v1/audiobooks/$jobId/file").apply {
|
||||
readTimeout = DOWNLOAD_TIMEOUT_MS
|
||||
}
|
||||
try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IOException(connection.errorDescription())
|
||||
}
|
||||
destination.parentFile?.mkdirs()
|
||||
connection.inputStream.buffered(256 * 1024).use { input ->
|
||||
FileOutputStream(destination).buffered(256 * 1024).use { output ->
|
||||
input.copyTo(output, 256 * 1024)
|
||||
}
|
||||
}
|
||||
check(destination.isFile && destination.length() > 0L) {
|
||||
"Сервер вернул пустой M4A-файл"
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelOrDelete(jobId: String) {
|
||||
val connection = open("v1/audiobooks/$jobId").apply { requestMethod = "DELETE" }
|
||||
try {
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299 && code != HttpURLConnection.HTTP_NOT_FOUND) {
|
||||
throw IOException(connection.errorDescription())
|
||||
}
|
||||
(if (code in 200..299) connection.inputStream else connection.errorStream)?.close()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun open(path: String): HttpURLConnection {
|
||||
check(token.isNotBlank()) { "Ключ сервера аудиокниг не добавлен в сборку" }
|
||||
return (URL(baseUrl.trimEnd('/') + "/" + path).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
useCaches = false
|
||||
setRequestProperty("Authorization", "Bearer $token")
|
||||
setRequestProperty("Accept", "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
private fun HttpURLConnection.readSuccessfulBody(): String {
|
||||
val code = responseCode
|
||||
if (code !in 200..299) throw IOException(errorDescription())
|
||||
return inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
|
||||
}
|
||||
|
||||
private fun HttpURLConnection.errorDescription(): String {
|
||||
val details = errorStream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
return "Сервер аудиокниг вернул HTTP $responseCode" +
|
||||
details.takeIf(String::isNotBlank)?.let { ": $it" }.orEmpty()
|
||||
}
|
||||
|
||||
private fun parseJob(payload: String): RemoteJob {
|
||||
val json = JSONObject(payload)
|
||||
return RemoteJob(
|
||||
id = json.getString("id"),
|
||||
status = json.getString("status"),
|
||||
stage = json.optString("stage"),
|
||||
processedCharacters = json.optLong("processedCharacters"),
|
||||
totalCharacters = json.optLong("totalCharacters"),
|
||||
currentChapter = json.optString("currentChapter").takeIf { it.isNotBlank() && it != "null" },
|
||||
durationMs = json.optLong("durationMs"),
|
||||
chaptersJson = json.optJSONArray("chapters")?.toString() ?: "[]",
|
||||
error = json.optString("error").takeIf { it.isNotBlank() && it != "null" }
|
||||
)
|
||||
}
|
||||
|
||||
private fun BufferedOutputStream.writeTextPart(boundary: String, name: String, value: String) {
|
||||
writeAscii("--$boundary\r\n")
|
||||
writeAscii("Content-Disposition: form-data; name=\"$name\"\r\n")
|
||||
writeAscii("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
|
||||
write(value.toByteArray(StandardCharsets.UTF_8))
|
||||
writeAscii("\r\n")
|
||||
}
|
||||
|
||||
private fun BufferedOutputStream.writeAscii(value: String) {
|
||||
write(value.toByteArray(StandardCharsets.US_ASCII))
|
||||
}
|
||||
|
||||
private fun contentType(file: File): String = when (file.extension.lowercase()) {
|
||||
"epub" -> "application/epub+zip"
|
||||
"fb2" -> "application/x-fictionbook+xml"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
|
||||
data class RemoteJob(
|
||||
val id: String,
|
||||
val status: String,
|
||||
val stage: String,
|
||||
val processedCharacters: Long,
|
||||
val totalCharacters: Long,
|
||||
val currentChapter: String?,
|
||||
val durationMs: Long,
|
||||
val chaptersJson: String,
|
||||
val error: String?
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val CONNECT_TIMEOUT_MS = 30_000
|
||||
const val READ_TIMEOUT_MS = 60_000
|
||||
const val DOWNLOAD_TIMEOUT_MS = 10 * 60_000
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import com.aletheia.app.model.AudioBook
|
||||
import com.aletheia.app.model.Book
|
||||
import com.aletheia.app.model.ReadingBookmark
|
||||
import com.aletheia.app.model.ReadingNote
|
||||
@@ -61,6 +62,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
|
||||
createBookmarksTable(db)
|
||||
createNotesTable(db)
|
||||
createAudioBooksTable(db)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
@@ -86,6 +88,12 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
db.execSQL("ALTER TABLE reading_notes ADD COLUMN kind TEXT NOT NULL DEFAULT 'note'")
|
||||
}
|
||||
}
|
||||
if (oldVersion < 7) {
|
||||
createAudioBooksTable(db)
|
||||
}
|
||||
if (oldVersion < 8 && !hasColumn(db, "audiobooks", "remote_job_id")) {
|
||||
db.execSQL("ALTER TABLE audiobooks ADD COLUMN remote_job_id TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllBooks(): List<Book> = readableDatabase.query(
|
||||
@@ -133,6 +141,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
writableDatabase.delete("reading_progress", "book_id = ?", arrayOf(book.id.toString()))
|
||||
writableDatabase.delete("reading_bookmarks", "book_id = ?", arrayOf(book.id.toString()))
|
||||
writableDatabase.delete("reading_notes", "book_id = ?", arrayOf(book.id.toString()))
|
||||
writableDatabase.delete("audiobooks", "source_book_id = ?", arrayOf(book.id.toString()))
|
||||
writableDatabase.delete("books", "id = ?", arrayOf(book.id.toString()))
|
||||
writableDatabase.setTransactionSuccessful()
|
||||
} finally {
|
||||
@@ -153,6 +162,34 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
|
||||
fun getAllAudioBooks(): List<AudioBook> = readableDatabase.query(
|
||||
"audiobooks", null, null, null, null, null, "updated_at DESC"
|
||||
).use { cursor ->
|
||||
buildList { while (cursor.moveToNext()) add(cursorToAudioBook(cursor)) }
|
||||
}
|
||||
|
||||
fun getAudioBookById(id: Long): AudioBook? = readableDatabase.query(
|
||||
"audiobooks", null, "id = ?", arrayOf(id.toString()), null, null, null, "1"
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursorToAudioBook(cursor) else null }
|
||||
|
||||
fun getAudioBookBySourceBookId(bookId: Long): AudioBook? = readableDatabase.query(
|
||||
"audiobooks", null, "source_book_id = ?", arrayOf(bookId.toString()), null, null, null, "1"
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursorToAudioBook(cursor) else null }
|
||||
|
||||
fun saveAudioBook(audioBook: AudioBook): Long {
|
||||
val values = audioBookToValues(audioBook)
|
||||
return if (audioBook.id == 0L) {
|
||||
writableDatabase.insertWithOnConflict("audiobooks", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
} else {
|
||||
writableDatabase.update("audiobooks", values, "id = ?", arrayOf(audioBook.id.toString()))
|
||||
audioBook.id
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAudioBook(id: Long) {
|
||||
writableDatabase.delete("audiobooks", "id = ?", arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
fun getSettings(keys: Collection<String>): Map<String, String> {
|
||||
if (keys.isEmpty()) return emptyMap()
|
||||
val orderedKeys = keys.distinct()
|
||||
@@ -368,6 +405,24 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
put("remote_id", book.remoteId)
|
||||
}
|
||||
|
||||
private fun audioBookToValues(audioBook: AudioBook) = ContentValues().apply {
|
||||
put("source_book_id", audioBook.sourceBookId)
|
||||
put("title", audioBook.title)
|
||||
put("author", audioBook.author)
|
||||
put("file_path", audioBook.filePath)
|
||||
put("remote_job_id", audioBook.remoteJobId)
|
||||
put("status", audioBook.status.databaseValue)
|
||||
put("processed_characters", audioBook.processedCharacters)
|
||||
put("total_characters", audioBook.totalCharacters)
|
||||
put("duration_ms", audioBook.durationMs)
|
||||
put("position_ms", audioBook.positionMs)
|
||||
put("chapters_json", audioBook.chaptersJson)
|
||||
put("current_chapter", audioBook.currentChapter)
|
||||
put("error_message", audioBook.errorMessage)
|
||||
put("created_at", audioBook.createdAt)
|
||||
put("updated_at", audioBook.updatedAt)
|
||||
}
|
||||
|
||||
private fun cursorToBook(cursor: Cursor) = Book(
|
||||
id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
|
||||
title = cursor.getString(cursor.getColumnIndexOrThrow("title")),
|
||||
@@ -387,6 +442,25 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
remoteId = cursor.getString(cursor.getColumnIndexOrThrow("remote_id"))
|
||||
)
|
||||
|
||||
private fun cursorToAudioBook(cursor: Cursor) = AudioBook(
|
||||
id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
|
||||
sourceBookId = cursor.getLong(cursor.getColumnIndexOrThrow("source_book_id")),
|
||||
title = cursor.getString(cursor.getColumnIndexOrThrow("title")),
|
||||
author = cursor.getString(cursor.getColumnIndexOrThrow("author")),
|
||||
filePath = cursor.getString(cursor.getColumnIndexOrThrow("file_path")),
|
||||
remoteJobId = cursor.getString(cursor.getColumnIndexOrThrow("remote_job_id")),
|
||||
status = AudioBook.Status.fromDatabase(cursor.getString(cursor.getColumnIndexOrThrow("status"))),
|
||||
processedCharacters = cursor.getLong(cursor.getColumnIndexOrThrow("processed_characters")),
|
||||
totalCharacters = cursor.getLong(cursor.getColumnIndexOrThrow("total_characters")),
|
||||
durationMs = cursor.getLong(cursor.getColumnIndexOrThrow("duration_ms")),
|
||||
positionMs = cursor.getLong(cursor.getColumnIndexOrThrow("position_ms")),
|
||||
chaptersJson = cursor.getString(cursor.getColumnIndexOrThrow("chapters_json")),
|
||||
currentChapter = cursor.getString(cursor.getColumnIndexOrThrow("current_chapter")),
|
||||
errorMessage = cursor.getString(cursor.getColumnIndexOrThrow("error_message")),
|
||||
createdAt = cursor.getLong(cursor.getColumnIndexOrThrow("created_at")),
|
||||
updatedAt = cursor.getLong(cursor.getColumnIndexOrThrow("updated_at"))
|
||||
)
|
||||
|
||||
private fun cursorToBookmark(cursor: Cursor) = ReadingBookmark(
|
||||
id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
|
||||
bookId = cursor.getLong(cursor.getColumnIndexOrThrow("book_id")),
|
||||
@@ -474,6 +548,37 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAudioBooksTable(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS audiobooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_book_id INTEGER NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
remote_job_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
processed_characters INTEGER NOT NULL DEFAULT 0,
|
||||
total_characters INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
position_ms INTEGER NOT NULL DEFAULT 0,
|
||||
chapters_json TEXT NOT NULL DEFAULT '[]',
|
||||
current_chapter TEXT,
|
||||
error_message TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_audiobooks_status
|
||||
ON audiobooks(status, updated_at DESC)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasColumn(db: SQLiteDatabase, tableName: String, columnName: String): Boolean {
|
||||
db.rawQuery("PRAGMA table_info($tableName)", null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
@@ -487,7 +592,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "aletheia.db3"
|
||||
private const val DATABASE_VERSION = 6
|
||||
private const val DATABASE_VERSION = 8
|
||||
private const val MAX_READING_HISTORY_ENTRIES = 200
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.aletheia.app.data
|
||||
|
||||
import android.content.Context
|
||||
import com.aletheia.app.model.AudioBook
|
||||
import com.aletheia.app.model.Book
|
||||
import java.io.File
|
||||
|
||||
class AudioBookRepository(
|
||||
context: Context,
|
||||
private val databaseHelper: AppDatabaseHelper
|
||||
) {
|
||||
private val outputDirectory = File(context.cacheDir, "audiobooks").apply { mkdirs() }
|
||||
|
||||
fun getAll(): List<AudioBook> = databaseHelper.getAllAudioBooks().map(::reconcileFile)
|
||||
|
||||
fun getById(id: Long): AudioBook? = databaseHelper.getAudioBookById(id)?.let(::reconcileFile)
|
||||
|
||||
fun getBySourceBookId(bookId: Long): AudioBook? =
|
||||
databaseHelper.getAudioBookBySourceBookId(bookId)?.let(::reconcileFile)
|
||||
|
||||
fun createOrReset(book: Book): AudioBook {
|
||||
val existing = databaseHelper.getAudioBookBySourceBookId(book.id)
|
||||
existing?.filePath?.takeIf(String::isNotBlank)?.let { File(it).delete() }
|
||||
val now = System.currentTimeMillis()
|
||||
val audioBook = AudioBook(
|
||||
id = existing?.id ?: 0,
|
||||
sourceBookId = book.id,
|
||||
title = book.title,
|
||||
author = book.author,
|
||||
remoteJobId = null,
|
||||
status = AudioBook.Status.QUEUED,
|
||||
createdAt = existing?.createdAt ?: now,
|
||||
updatedAt = now
|
||||
)
|
||||
val id = databaseHelper.saveAudioBook(audioBook)
|
||||
return requireNotNull(databaseHelper.getAudioBookById(id))
|
||||
}
|
||||
|
||||
fun save(audioBook: AudioBook): AudioBook {
|
||||
val id = databaseHelper.saveAudioBook(audioBook.copy(updatedAt = System.currentTimeMillis()))
|
||||
return requireNotNull(databaseHelper.getAudioBookById(id))
|
||||
}
|
||||
|
||||
fun savePosition(id: Long, positionMs: Long) {
|
||||
val current = databaseHelper.getAudioBookById(id) ?: return
|
||||
databaseHelper.saveAudioBook(
|
||||
current.copy(positionMs = positionMs.coerceAtLeast(0), updatedAt = System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
|
||||
fun outputFile(audioBookId: Long): File = File(outputDirectory, "audiobook-$audioBookId.m4a")
|
||||
|
||||
fun partialOutputFile(audioBookId: Long): File = File(outputDirectory, "audiobook-$audioBookId.partial.m4a")
|
||||
|
||||
fun delete(audioBook: AudioBook) {
|
||||
audioBook.filePath.takeIf(String::isNotBlank)?.let { File(it).delete() }
|
||||
partialOutputFile(audioBook.id).delete()
|
||||
databaseHelper.deleteAudioBook(audioBook.id)
|
||||
}
|
||||
|
||||
private fun reconcileFile(audioBook: AudioBook): AudioBook {
|
||||
if (audioBook.status != AudioBook.Status.READY || File(audioBook.filePath).isFile) {
|
||||
return audioBook
|
||||
}
|
||||
return save(
|
||||
audioBook.copy(
|
||||
status = AudioBook.Status.FAILED,
|
||||
errorMessage = "Файл аудиокниги удалён системой из кэша. Создайте его повторно."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.aletheia.app.model
|
||||
|
||||
data class AudioBook(
|
||||
val id: Long = 0,
|
||||
val sourceBookId: Long,
|
||||
val title: String,
|
||||
val author: String,
|
||||
val filePath: String = "",
|
||||
val remoteJobId: String? = null,
|
||||
val status: Status = Status.QUEUED,
|
||||
val processedCharacters: Long = 0,
|
||||
val totalCharacters: Long = 0,
|
||||
val durationMs: Long = 0,
|
||||
val positionMs: Long = 0,
|
||||
val chaptersJson: String = "[]",
|
||||
val currentChapter: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val updatedAt: Long = System.currentTimeMillis()
|
||||
) {
|
||||
val progressPercent: Int
|
||||
get() = if (totalCharacters <= 0) 0 else
|
||||
((processedCharacters * 100L) / totalCharacters).toInt().coerceIn(0, 100)
|
||||
|
||||
enum class Status(val databaseValue: String) {
|
||||
QUEUED("queued"), GENERATING("generating"), READY("ready"),
|
||||
FAILED("failed"), CANCELLED("cancelled");
|
||||
|
||||
companion object {
|
||||
fun fromDatabase(value: String?): Status =
|
||||
entries.firstOrNull { it.databaseValue == value } ?: FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.aletheia.app.ui.audiobook
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.SeekBar
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.aletheia.app.AletheiaApplication
|
||||
import com.aletheia.app.R
|
||||
import com.aletheia.app.databinding.ActivityAudioBookPlayerBinding
|
||||
import com.aletheia.app.model.AudioBook
|
||||
import com.aletheia.app.ui.reader.ReaderActivity
|
||||
import com.aletheia.app.util.setBookCover
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
|
||||
class AudioBookPlayerActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityAudioBookPlayerBinding
|
||||
private val app by lazy { application as AletheiaApplication }
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var audioBook: AudioBook? = null
|
||||
private var chapters: List<Chapter> = emptyList()
|
||||
private var prepared = false
|
||||
private var userSeeking = false
|
||||
private var speedIndex = 1
|
||||
private var sleepAtMs: Long? = null
|
||||
|
||||
private val progressUpdater = object : Runnable {
|
||||
override fun run() {
|
||||
updateProgress()
|
||||
handler.postDelayed(this, 500)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityAudioBookPlayerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setupControls()
|
||||
val id = intent.getLongExtra(EXTRA_AUDIOBOOK_ID, 0L)
|
||||
if (id <= 0) finish() else loadAudioBook(id)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
handler.post(progressUpdater)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
savePosition()
|
||||
handler.removeCallbacks(progressUpdater)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
mediaPlayer?.release()
|
||||
mediaPlayer = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun setupControls() {
|
||||
binding.playerBackButton.setOnClickListener { finish() }
|
||||
binding.playerPlayButton.setOnClickListener { togglePlayback() }
|
||||
binding.playerRewindButton.setOnClickListener { seekBy(-15_000) }
|
||||
binding.playerForwardButton.setOnClickListener { seekBy(30_000) }
|
||||
binding.playerSpeedButton.setOnClickListener { cycleSpeed() }
|
||||
binding.playerSleepButton.setOnClickListener { showSleepTimer() }
|
||||
binding.playerReadButton.setOnClickListener {
|
||||
audioBook?.let { startActivity(Intent(this, ReaderActivity::class.java).putExtra(ReaderActivity.EXTRA_BOOK_ID, it.sourceBookId)) }
|
||||
}
|
||||
binding.playerMoreButton.setOnClickListener { anchor ->
|
||||
PopupMenu(this, anchor).apply {
|
||||
menu.add("Начать сначала").setOnMenuItemClickListener {
|
||||
mediaPlayer?.seekTo(0)
|
||||
updateProgress()
|
||||
true
|
||||
}
|
||||
menu.add("Открыть электронную книгу").setOnMenuItemClickListener {
|
||||
binding.playerReadButton.performClick()
|
||||
true
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
binding.playerSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) { userSeeking = true }
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar?) {
|
||||
val duration = mediaPlayer?.duration?.takeIf { it > 0 } ?: 0
|
||||
mediaPlayer?.seekTo(duration * (seekBar?.progress ?: 0) / 1000)
|
||||
userSeeking = false
|
||||
updateProgress()
|
||||
}
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) = Unit
|
||||
})
|
||||
}
|
||||
|
||||
private fun loadAudioBook(id: Long) {
|
||||
lifecycleScope.launch {
|
||||
val data = withContext(Dispatchers.IO) {
|
||||
val audio = app.audioBookRepository.getById(id)
|
||||
val book = audio?.let { app.bookRepository.getBookById(it.sourceBookId) }
|
||||
audio to book
|
||||
}
|
||||
val audio = data.first
|
||||
if (audio == null || audio.status != AudioBook.Status.READY || !File(audio.filePath).isFile) {
|
||||
Toast.makeText(this@AudioBookPlayerActivity, "Файл аудиокниги недоступен", Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
return@launch
|
||||
}
|
||||
audioBook = audio
|
||||
chapters = parseChapters(audio.chaptersJson)
|
||||
binding.playerTitle.text = audio.title
|
||||
binding.playerAuthor.text = audio.author
|
||||
binding.playerCover.setBookCover(data.second?.coverImage, R.drawable.default_cover)
|
||||
preparePlayer(audio)
|
||||
}
|
||||
}
|
||||
|
||||
private fun preparePlayer(audio: AudioBook) {
|
||||
mediaPlayer = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
setDataSource(audio.filePath)
|
||||
setOnPreparedListener { player ->
|
||||
prepared = true
|
||||
player.seekTo(audio.positionMs.coerceAtMost(player.duration.toLong()).toInt())
|
||||
applySpeed()
|
||||
updateProgress()
|
||||
}
|
||||
setOnCompletionListener {
|
||||
binding.playerPlayButton.text = "▶"
|
||||
updateProgress()
|
||||
savePosition()
|
||||
}
|
||||
setOnErrorListener { _, _, _ ->
|
||||
Toast.makeText(this@AudioBookPlayerActivity, "Не удалось воспроизвести M4A-файл", Toast.LENGTH_LONG).show()
|
||||
true
|
||||
}
|
||||
prepareAsync()
|
||||
}
|
||||
}
|
||||
|
||||
private fun togglePlayback() {
|
||||
val player = mediaPlayer ?: return
|
||||
if (!prepared) return
|
||||
if (player.isPlaying) player.pause() else player.start()
|
||||
binding.playerPlayButton.text = if (player.isPlaying) "Ⅱ" else "▶"
|
||||
}
|
||||
|
||||
private fun seekBy(deltaMs: Int) {
|
||||
val player = mediaPlayer ?: return
|
||||
if (!prepared) return
|
||||
player.seekTo((player.currentPosition + deltaMs).coerceIn(0, player.duration))
|
||||
updateProgress()
|
||||
}
|
||||
|
||||
private fun cycleSpeed() {
|
||||
speedIndex = (speedIndex + 1) % SPEEDS.size
|
||||
applySpeed()
|
||||
}
|
||||
|
||||
private fun applySpeed() {
|
||||
val speed = SPEEDS[speedIndex]
|
||||
binding.playerSpeedButton.text = "${speed.toDisplay()}x"
|
||||
if (prepared) runCatching {
|
||||
mediaPlayer?.playbackParams = mediaPlayer!!.playbackParams.setSpeed(speed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSleepTimer() {
|
||||
val labels = arrayOf("Выключен", "15 минут", "30 минут", "45 минут", "60 минут")
|
||||
val minutes = intArrayOf(0, 15, 30, 45, 60)
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Таймер сна")
|
||||
.setItems(labels) { _, index ->
|
||||
sleepAtMs = minutes[index].takeIf { it > 0 }?.let { System.currentTimeMillis() + it * 60_000L }
|
||||
binding.playerSleepButton.alpha = if (sleepAtMs == null) 1f else 0.45f
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun updateProgress() {
|
||||
val player = mediaPlayer ?: return
|
||||
if (!prepared) return
|
||||
sleepAtMs?.let { deadline ->
|
||||
if (System.currentTimeMillis() >= deadline) {
|
||||
if (player.isPlaying) player.pause()
|
||||
binding.playerPlayButton.text = "▶"
|
||||
sleepAtMs = null
|
||||
binding.playerSleepButton.alpha = 1f
|
||||
}
|
||||
}
|
||||
val position = player.currentPosition.coerceAtLeast(0)
|
||||
val duration = player.duration.coerceAtLeast(1)
|
||||
if (!userSeeking) binding.playerSeekBar.progress = position * 1000 / duration
|
||||
binding.playerElapsed.text = formatTime(position.toLong())
|
||||
binding.playerRemaining.text = "-${formatTime((duration - position).toLong())}"
|
||||
binding.playerRemainingSummary.text = remainingSummary((duration - position).toLong())
|
||||
binding.playerChapter.text = chapters.lastOrNull { it.startMs <= position }?.title ?: "Вся книга"
|
||||
}
|
||||
|
||||
private fun savePosition() {
|
||||
val id = audioBook?.id ?: return
|
||||
val position = mediaPlayer?.currentPosition?.toLong() ?: return
|
||||
app.applicationScope.launch { app.audioBookRepository.savePosition(id, position) }
|
||||
}
|
||||
|
||||
private fun parseChapters(json: String): List<Chapter> = runCatching {
|
||||
val array = JSONArray(json)
|
||||
buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
val item = array.getJSONObject(index)
|
||||
add(Chapter(item.getString("title"), item.getLong("startMs")))
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun remainingSummary(milliseconds: Long): String {
|
||||
val totalMinutes = milliseconds.coerceAtLeast(0) / 60_000
|
||||
val hours = totalMinutes / 60
|
||||
val minutes = totalMinutes % 60
|
||||
return if (hours > 0) "$hours ч $minutes мин до конца книги" else "$minutes мин до конца книги"
|
||||
}
|
||||
|
||||
private fun formatTime(milliseconds: Long): String {
|
||||
val seconds = milliseconds.coerceAtLeast(0) / 1000
|
||||
val hours = seconds / 3600
|
||||
val minutes = (seconds % 3600) / 60
|
||||
val rest = seconds % 60
|
||||
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, rest) else "%02d:%02d".format(minutes, rest)
|
||||
}
|
||||
|
||||
private fun Float.toDisplay(): String = if (this % 1f == 0f) toInt().toString() else
|
||||
String.format(Locale.US, "%.2f", this).trimEnd('0').trimEnd('.')
|
||||
|
||||
private data class Chapter(val title: String, val startMs: Long)
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_AUDIOBOOK_ID = "audio_book_id"
|
||||
private val SPEEDS = floatArrayOf(0.75f, 1f, 1.25f, 1.5f, 2f)
|
||||
|
||||
fun createIntent(context: Context, audioBookId: Long) =
|
||||
Intent(context, AudioBookPlayerActivity::class.java).putExtra(EXTRA_AUDIOBOOK_ID, audioBookId)
|
||||
}
|
||||
}
|
||||
@@ -8,71 +8,66 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
import com.aletheia.app.R
|
||||
import com.aletheia.app.databinding.ItemBookBinding
|
||||
import com.aletheia.app.databinding.ItemShelfAddBinding
|
||||
import com.aletheia.app.model.AudioBook
|
||||
import com.aletheia.app.model.Book
|
||||
import com.aletheia.app.util.setBookCover
|
||||
|
||||
class BooksAdapter(
|
||||
private val onBookClicked: (Book) -> Unit,
|
||||
private val onAudioBookClicked: (AudioBook) -> Unit = {},
|
||||
private val onAddBookClicked: () -> Unit,
|
||||
private val onBookLongPressed: ((Book) -> Unit)? = null,
|
||||
private val onBookActionsClicked: ((View, Book) -> Unit)? = null,
|
||||
private val onAudioBookActionsClicked: ((View, AudioBook) -> Unit)? = null,
|
||||
private val includeAddItem: Boolean = true
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private val items = mutableListOf<Book>()
|
||||
private val items = mutableListOf<ShelfItem>()
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
init { setHasStableIds(true) }
|
||||
|
||||
fun submitList(books: List<Book>) = submitContent(books, emptyList())
|
||||
|
||||
fun updateBook(book: Book) {
|
||||
val currentBooks = items.mapNotNull { (it as? ShelfItem.TextBook)?.book }.map {
|
||||
if (it.id == book.id) book else it
|
||||
}
|
||||
val currentAudio = items.mapNotNull { it as? ShelfItem.Audio }.map { it.audioBook to it.coverImage }
|
||||
submitContent(currentBooks, currentAudio)
|
||||
}
|
||||
|
||||
fun submitList(books: List<Book>) {
|
||||
val oldItems = items.toList()
|
||||
fun submitContent(books: List<Book>, audioBooks: List<Pair<AudioBook, ByteArray?>>) {
|
||||
val next = books.map(ShelfItem::TextBook) + audioBooks.map { (audioBook, cover) ->
|
||||
ShelfItem.Audio(audioBook, cover)
|
||||
}
|
||||
val old = items.toList()
|
||||
val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun getOldListSize(): Int = oldItems.shelfItemCount()
|
||||
override fun getNewListSize(): Int = books.shelfItemCount()
|
||||
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldIsAddItem = oldItemPosition >= oldItems.size
|
||||
val newIsAddItem = newItemPosition >= books.size
|
||||
return when {
|
||||
oldIsAddItem && newIsAddItem -> true
|
||||
oldIsAddItem || newIsAddItem -> false
|
||||
else -> oldItems[oldItemPosition].id == books[newItemPosition].id
|
||||
}
|
||||
override fun getOldListSize() = old.itemCount()
|
||||
override fun getNewListSize() = next.itemCount()
|
||||
override fun areItemsTheSame(oldPosition: Int, newPosition: Int): Boolean {
|
||||
val oldItem = old.getOrNull(oldPosition)
|
||||
val newItem = next.getOrNull(newPosition)
|
||||
return if (oldItem == null || newItem == null) oldItem == newItem else oldItem.stableId == newItem.stableId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldIsAddItem = oldItemPosition >= oldItems.size
|
||||
val newIsAddItem = newItemPosition >= books.size
|
||||
return when {
|
||||
oldIsAddItem && newIsAddItem -> true
|
||||
oldIsAddItem || newIsAddItem -> false
|
||||
else -> oldItems[oldItemPosition].hasSameVisibleContent(books[newItemPosition])
|
||||
}
|
||||
override fun areContentsTheSame(oldPosition: Int, newPosition: Int): Boolean {
|
||||
val oldItem = old.getOrNull(oldPosition)
|
||||
val newItem = next.getOrNull(newPosition)
|
||||
return if (oldItem == null || newItem == null) oldItem == newItem else oldItem.sameVisibleContent(newItem)
|
||||
}
|
||||
})
|
||||
|
||||
items.clear()
|
||||
items.addAll(books)
|
||||
items.addAll(next)
|
||||
diff.dispatchUpdatesTo(this)
|
||||
}
|
||||
|
||||
fun updateBook(book: Book) {
|
||||
val index = items.indexOfFirst { it.id == book.id }
|
||||
if (index < 0) return
|
||||
items[index] = book
|
||||
notifyItemChanged(index)
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int =
|
||||
if (position < items.size) VIEW_TYPE_BOOK else VIEW_TYPE_ADD
|
||||
override fun getItemViewType(position: Int) = if (position < items.size) VIEW_TYPE_BOOK else VIEW_TYPE_ADD
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return when (viewType) {
|
||||
VIEW_TYPE_BOOK -> BookViewHolder(ItemBookBinding.inflate(inflater, parent, false))
|
||||
VIEW_TYPE_ADD -> AddBookViewHolder(ItemShelfAddBinding.inflate(inflater, parent, false))
|
||||
else -> error("Unsupported bookshelf item view type: $viewType")
|
||||
return if (viewType == VIEW_TYPE_BOOK) {
|
||||
BookViewHolder(ItemBookBinding.inflate(inflater, parent, false))
|
||||
} else {
|
||||
AddBookViewHolder(ItemShelfAddBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,72 +78,88 @@ class BooksAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.shelfItemCount()
|
||||
|
||||
override fun getItemId(position: Int): Long =
|
||||
if (position < items.size) items[position].id else ADD_ITEM_ID
|
||||
override fun getItemCount() = items.itemCount()
|
||||
override fun getItemId(position: Int) = items.getOrNull(position)?.stableId ?: ADD_ITEM_ID
|
||||
|
||||
private inner class BookViewHolder(private val binding: ItemBookBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(book: Book) {
|
||||
fun bind(item: ShelfItem) {
|
||||
val context = binding.root.context
|
||||
binding.bookTitle.text = book.title
|
||||
binding.bookAuthor.text = book.author
|
||||
binding.bookProgressText.text = book.progressText
|
||||
binding.bookProgressBar.progress = (book.readingProgress * 100).toInt()
|
||||
binding.bookCover.setBookCover(book.coverImage, R.drawable.default_cover)
|
||||
binding.root.contentDescription = context.getString(
|
||||
R.string.a11y_book_card,
|
||||
book.title,
|
||||
book.author,
|
||||
book.progressText
|
||||
)
|
||||
binding.root.setOnClickListener { onBookClicked(book) }
|
||||
if (onBookLongPressed == null) {
|
||||
binding.root.setOnLongClickListener(null)
|
||||
binding.root.isLongClickable = false
|
||||
} else {
|
||||
binding.root.setOnLongClickListener {
|
||||
onBookLongPressed.invoke(book)
|
||||
true
|
||||
when (item) {
|
||||
is ShelfItem.TextBook -> {
|
||||
val book = item.book
|
||||
binding.bookTitle.text = book.title
|
||||
binding.bookAuthor.text = book.author
|
||||
binding.bookProgressText.text = book.progressText
|
||||
binding.bookProgressBar.progress = (book.readingProgress * 100).toInt()
|
||||
binding.bookCover.setBookCover(book.coverImage, R.drawable.default_cover)
|
||||
binding.root.contentDescription = context.getString(
|
||||
R.string.a11y_book_card, book.title, book.author, book.progressText
|
||||
)
|
||||
binding.root.setOnClickListener { onBookClicked(book) }
|
||||
binding.root.setOnLongClickListener(if (onBookLongPressed == null) null else View.OnLongClickListener {
|
||||
onBookLongPressed.invoke(book)
|
||||
true
|
||||
})
|
||||
bindActions { anchor -> onBookActionsClicked?.invoke(anchor, book) }
|
||||
}
|
||||
}
|
||||
if (onBookActionsClicked == null) {
|
||||
binding.bookActions.visibility = View.GONE
|
||||
binding.bookActions.setOnClickListener(null)
|
||||
} else {
|
||||
binding.bookActions.visibility = View.VISIBLE
|
||||
binding.bookActions.contentDescription = context.getString(R.string.a11y_book_actions, book.title)
|
||||
binding.bookActions.setOnClickListener { anchor ->
|
||||
onBookActionsClicked.invoke(anchor, book)
|
||||
is ShelfItem.Audio -> {
|
||||
val audio = item.audioBook
|
||||
val progress = when {
|
||||
audio.status == AudioBook.Status.GENERATING || audio.status == AudioBook.Status.QUEUED ->
|
||||
audio.progressPercent
|
||||
audio.durationMs > 0 -> ((audio.positionMs * 100L) / audio.durationMs).toInt().coerceIn(0, 100)
|
||||
else -> 0
|
||||
}
|
||||
val status = when (audio.status) {
|
||||
AudioBook.Status.READY -> "Аудиокнига · $progress%"
|
||||
AudioBook.Status.GENERATING -> "Создание · ${audio.progressPercent}%"
|
||||
AudioBook.Status.QUEUED -> "В очереди на создание"
|
||||
AudioBook.Status.FAILED -> "Ошибка создания"
|
||||
AudioBook.Status.CANCELLED -> "Создание отменено"
|
||||
}
|
||||
binding.bookTitle.text = audio.title
|
||||
binding.bookAuthor.text = audio.author
|
||||
binding.bookProgressText.text = status
|
||||
binding.bookProgressBar.progress = progress
|
||||
binding.bookCover.setBookCover(item.coverImage, R.drawable.default_cover)
|
||||
binding.root.contentDescription = "${audio.title}. ${audio.author}. $status"
|
||||
binding.root.setOnLongClickListener(null)
|
||||
binding.root.setOnClickListener { onAudioBookClicked(audio) }
|
||||
bindActions { anchor -> onAudioBookActionsClicked?.invoke(anchor, audio) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindActions(action: ((View) -> Unit)?) {
|
||||
binding.bookActions.visibility = if (action == null) View.GONE else View.VISIBLE
|
||||
binding.bookActions.setOnClickListener(action)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class AddBookViewHolder(private val binding: ItemShelfAddBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind() {
|
||||
binding.root.setOnClickListener { onAddBookClicked() }
|
||||
fun bind() { binding.root.setOnClickListener { onAddBookClicked() } }
|
||||
}
|
||||
|
||||
private sealed class ShelfItem(val stableId: Long) {
|
||||
data class TextBook(val book: Book) : ShelfItem(book.id)
|
||||
data class Audio(val audioBook: AudioBook, val coverImage: ByteArray?) :
|
||||
ShelfItem(Long.MIN_VALUE + audioBook.id)
|
||||
|
||||
fun sameVisibleContent(other: ShelfItem?): Boolean = when {
|
||||
this is TextBook && other is TextBook -> book.copy(coverImage = null) == other.book.copy(coverImage = null) &&
|
||||
bytesEqual(book.coverImage, other.book.coverImage)
|
||||
this is Audio && other is Audio -> audioBook == other.audioBook && bytesEqual(coverImage, other.coverImage)
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun bytesEqual(first: ByteArray?, second: ByteArray?) = when {
|
||||
first === second -> true
|
||||
first == null || second == null -> false
|
||||
else -> first.contentEquals(second)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Book.hasSameVisibleContent(other: Book): Boolean =
|
||||
title == other.title &&
|
||||
author == other.author &&
|
||||
readingProgress == other.readingProgress &&
|
||||
currentPage == other.currentPage &&
|
||||
totalPages == other.totalPages &&
|
||||
lastRead == other.lastRead &&
|
||||
coverImage.bytesEqual(other.coverImage)
|
||||
|
||||
private fun ByteArray?.bytesEqual(other: ByteArray?): Boolean = when {
|
||||
this === other -> true
|
||||
this == null || other == null -> false
|
||||
else -> contentEquals(other)
|
||||
}
|
||||
|
||||
private fun List<Book>.shelfItemCount(): Int =
|
||||
if (isEmpty()) 0 else size + if (includeAddItem) 1 else 0
|
||||
|
||||
private fun List<ShelfItem>.itemCount() = if (isEmpty()) 0 else size + if (includeAddItem) 1 else 0
|
||||
private companion object {
|
||||
const val VIEW_TYPE_BOOK = 1
|
||||
const val VIEW_TYPE_ADD = 2
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.aletheia.app.ui.books
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
@@ -23,10 +27,13 @@ import com.aletheia.app.R
|
||||
import com.aletheia.app.data.Result
|
||||
import com.aletheia.app.databinding.FragmentBookshelfBinding
|
||||
import com.aletheia.app.model.Book
|
||||
import com.aletheia.app.model.AudioBook
|
||||
import com.aletheia.app.model.QBooksBook
|
||||
import com.aletheia.app.ui.main.MainActivity
|
||||
import com.aletheia.app.ui.qbooks.BookDetailActivity
|
||||
import com.aletheia.app.ui.reader.ReaderActivity
|
||||
import com.aletheia.app.audiobook.AudioBookGenerationService
|
||||
import com.aletheia.app.ui.audiobook.AudioBookPlayerActivity
|
||||
import com.aletheia.app.util.BookSharing
|
||||
import com.aletheia.app.util.setBookCover
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
@@ -46,11 +53,22 @@ class BookshelfFragment : Fragment() {
|
||||
private lateinit var qBooksShelfAdapter: QBooksShelfAdapter
|
||||
private var currentBooks: List<Book> = emptyList()
|
||||
private var visibleBooks: List<Book> = emptyList()
|
||||
private var currentAudioBooks: List<AudioBook> = emptyList()
|
||||
private var visibleAudioBooks: List<AudioBook> = emptyList()
|
||||
private var continueReadingBook: Book? = null
|
||||
private val metadataJobs = mutableListOf<Job>()
|
||||
private var loadBooksJob: Job? = null
|
||||
private var libraryFilter = LibraryFilter.ALL
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { }
|
||||
private val generationReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AudioBookGenerationService.ACTION_PROGRESS && _binding != null) loadBooks()
|
||||
}
|
||||
}
|
||||
|
||||
private val importBookLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) {
|
||||
return@registerForActivityResult
|
||||
@@ -83,9 +101,11 @@ class BookshelfFragment : Fragment() {
|
||||
|
||||
booksAdapter = BooksAdapter(
|
||||
onBookClicked = ::openBook,
|
||||
onAudioBookClicked = ::openAudioBook,
|
||||
onAddBookClicked = ::launchImport,
|
||||
onBookLongPressed = ::confirmDeleteBook,
|
||||
onBookActionsClicked = ::showBookActions
|
||||
onBookActionsClicked = ::showBookActions,
|
||||
onAudioBookActionsClicked = ::showAudioBookActions
|
||||
)
|
||||
binding.booksRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
|
||||
binding.booksRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8)))
|
||||
@@ -150,6 +170,20 @@ class BookshelfFragment : Fragment() {
|
||||
loadBooks()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
ContextCompat.registerReceiver(
|
||||
requireContext(), generationReceiver,
|
||||
IntentFilter(AudioBookGenerationService.ACTION_PROGRESS),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
runCatching { requireContext().unregisterReceiver(generationReceiver) }
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onHiddenChanged(hidden: Boolean) {
|
||||
super.onHiddenChanged(hidden)
|
||||
if (!hidden && _binding != null) {
|
||||
@@ -169,14 +203,17 @@ class BookshelfFragment : Fragment() {
|
||||
private fun loadBooks() {
|
||||
loadBooksJob?.cancel()
|
||||
loadBooksJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = runCatching { app.bookRepository.getAllBooks() }
|
||||
val result = runCatching {
|
||||
app.bookRepository.getAllBooks() to app.audioBookRepository.getAll()
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (_binding == null) {
|
||||
return@withContext
|
||||
}
|
||||
|
||||
result.onSuccess { books ->
|
||||
result.onSuccess { (books, audioBooks) ->
|
||||
currentBooks = books
|
||||
currentAudioBooks = audioBooks
|
||||
renderFilteredBooks()
|
||||
enrichMissingMetadata(books.take(METADATA_PREFETCH_LIMIT))
|
||||
}.onFailure { exception ->
|
||||
@@ -205,15 +242,26 @@ class BookshelfFragment : Fragment() {
|
||||
private fun renderFilteredBooks() {
|
||||
val query = binding.booksSearchInput.text?.toString().orEmpty().trim()
|
||||
visibleBooks = filterBooks(currentBooks, query)
|
||||
booksAdapter.submitList(visibleBooks)
|
||||
visibleAudioBooks = filterAudioBooks(currentAudioBooks, query)
|
||||
val covers = currentBooks.associate { it.id to it.coverImage }
|
||||
booksAdapter.submitContent(
|
||||
visibleBooks,
|
||||
visibleAudioBooks.map { it to covers[it.sourceBookId] }
|
||||
)
|
||||
renderLibraryState(
|
||||
allBooks = currentBooks,
|
||||
displayedBooks = visibleBooks,
|
||||
displayedAudioBooks = visibleAudioBooks,
|
||||
query = query
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderLibraryState(allBooks: List<Book>, displayedBooks: List<Book>, query: String) {
|
||||
private fun renderLibraryState(
|
||||
allBooks: List<Book>,
|
||||
displayedBooks: List<Book>,
|
||||
displayedAudioBooks: List<AudioBook>,
|
||||
query: String
|
||||
) {
|
||||
val isFiltered = query.isNotBlank() || libraryFilter != LibraryFilter.ALL
|
||||
continueReadingBook = if (isFiltered) {
|
||||
null
|
||||
@@ -225,8 +273,8 @@ class BookshelfFragment : Fragment() {
|
||||
}
|
||||
|
||||
val isLibraryEmpty = allBooks.isEmpty()
|
||||
val isSearchEmpty = !isLibraryEmpty && displayedBooks.isEmpty()
|
||||
val hasVisibleBooks = displayedBooks.isNotEmpty()
|
||||
val isSearchEmpty = !isLibraryEmpty && displayedBooks.isEmpty() && displayedAudioBooks.isEmpty()
|
||||
val hasVisibleBooks = displayedBooks.isNotEmpty() || displayedAudioBooks.isNotEmpty()
|
||||
binding.emptyLibraryCard.visibility = if (isLibraryEmpty) View.VISIBLE else View.GONE
|
||||
binding.emptySearchCard.visibility = if (isSearchEmpty) View.VISIBLE else View.GONE
|
||||
binding.bookshelfPanel.visibility = if (hasVisibleBooks) View.VISIBLE else View.GONE
|
||||
@@ -318,6 +366,24 @@ class BookshelfFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterAudioBooks(audioBooks: List<AudioBook>, query: String): List<AudioBook> {
|
||||
val search = query.lowercase().trim()
|
||||
return audioBooks.filter { audioBook ->
|
||||
val matchesSearch = search.isBlank() ||
|
||||
audioBook.title.lowercase().contains(search) ||
|
||||
audioBook.author.lowercase().contains(search)
|
||||
val matchesFilter = when (libraryFilter) {
|
||||
LibraryFilter.ALL -> true
|
||||
LibraryFilter.DOWNLOADED -> audioBook.status == AudioBook.Status.READY
|
||||
LibraryFilter.READING -> audioBook.status == AudioBook.Status.READY &&
|
||||
audioBook.positionMs > 0 && audioBook.positionMs < audioBook.durationMs
|
||||
LibraryFilter.FINISHED -> audioBook.status == AudioBook.Status.READY &&
|
||||
audioBook.durationMs > 0 && audioBook.positionMs >= audioBook.durationMs - 2_000
|
||||
}
|
||||
matchesSearch && matchesFilter
|
||||
}
|
||||
}
|
||||
|
||||
private enum class LibraryFilter {
|
||||
ALL,
|
||||
READING,
|
||||
@@ -331,6 +397,21 @@ class BookshelfFragment : Fragment() {
|
||||
startActivity(Intent(requireContext(), ReaderActivity::class.java).putExtra(ReaderActivity.EXTRA_BOOK_ID, book.id))
|
||||
}
|
||||
|
||||
private fun openAudioBook(audioBook: AudioBook) {
|
||||
when (audioBook.status) {
|
||||
AudioBook.Status.READY -> startActivity(AudioBookPlayerActivity.createIntent(requireContext(), audioBook.id))
|
||||
AudioBook.Status.GENERATING, AudioBook.Status.QUEUED -> showGenerationProgress(audioBook)
|
||||
AudioBook.Status.FAILED, AudioBook.Status.CANCELLED -> MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Аудиокнига не создана")
|
||||
.setMessage(audioBook.errorMessage ?: "Создание было отменено. Можно запустить его повторно.")
|
||||
.setPositiveButton("Создать снова") { _, _ ->
|
||||
currentBooks.firstOrNull { it.id == audioBook.sourceBookId }?.let(::confirmCreateAudioBook)
|
||||
}
|
||||
.setNegativeButton(R.string.action_cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openQBooksBookPreview(book: QBooksBook) {
|
||||
startActivity(BookDetailActivity.createIntent(requireContext(), book))
|
||||
}
|
||||
@@ -348,6 +429,10 @@ class BookshelfFragment : Fragment() {
|
||||
inflate(R.menu.book_actions_menu)
|
||||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.action_create_audiobook -> {
|
||||
confirmCreateAudioBook(book)
|
||||
true
|
||||
}
|
||||
R.id.action_share_book -> {
|
||||
shareBook(book)
|
||||
true
|
||||
@@ -363,6 +448,86 @@ class BookshelfFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAudioBookActions(anchor: View, audioBook: AudioBook) {
|
||||
PopupMenu(requireContext(), anchor).apply {
|
||||
when (audioBook.status) {
|
||||
AudioBook.Status.GENERATING, AudioBook.Status.QUEUED -> menu.add("Отменить создание").setOnMenuItemClickListener {
|
||||
AudioBookGenerationService.cancel(requireContext(), audioBook.id)
|
||||
true
|
||||
}
|
||||
else -> menu.add("Создать заново").setOnMenuItemClickListener {
|
||||
currentBooks.firstOrNull { it.id == audioBook.sourceBookId }?.let(::confirmCreateAudioBook)
|
||||
true
|
||||
}
|
||||
}
|
||||
menu.add("Удалить аудиокнигу").setOnMenuItemClickListener {
|
||||
confirmDeleteAudioBook(audioBook)
|
||||
true
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmCreateAudioBook(book: Book) {
|
||||
val existing = currentAudioBooks.firstOrNull { it.sourceBookId == book.id }
|
||||
val warning = if (existing?.status == AudioBook.Status.READY) {
|
||||
"Существующий аудиофайл будет заменён. "
|
||||
} else ""
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Создать аудиокнигу?")
|
||||
.setMessage(
|
||||
warning + "Книга будет отправлена на ваш CUDA-сервер и озвучена мужским голосом Qwen. " +
|
||||
"Приложение покажет прогресс и скачает готовый M4A в свой кэш. Создание может занять продолжительное время."
|
||||
)
|
||||
.setPositiveButton("Начать") { _, _ -> startAudioBookGeneration(book) }
|
||||
.setNegativeButton(R.string.action_cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun startAudioBookGeneration(book: Book) {
|
||||
if (Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val record = app.audioBookRepository.createOrReset(book)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (_binding == null) return@withContext
|
||||
AudioBookGenerationService.start(requireContext(), record.id)
|
||||
loadBooks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showGenerationProgress(audioBook: AudioBook) {
|
||||
val chapter = audioBook.currentChapter?.let { "\nСейчас: $it" }.orEmpty()
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Создание аудиокниги · ${audioBook.progressPercent}%")
|
||||
.setMessage("Обработано ${audioBook.processedCharacters} из ${audioBook.totalCharacters} символов.$chapter")
|
||||
.setPositiveButton("Продолжить в фоне", null)
|
||||
.setNegativeButton("Отменить") { _, _ ->
|
||||
AudioBookGenerationService.cancel(requireContext(), audioBook.id)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun confirmDeleteAudioBook(audioBook: AudioBook) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Удалить аудиокнигу?")
|
||||
.setMessage("Исходная электронная книга останется в библиотеке.")
|
||||
.setPositiveButton(R.string.action_delete) { _, _ ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
AudioBookGenerationService.cancel(requireContext(), audioBook.id)
|
||||
app.audioBookRepository.delete(audioBook)
|
||||
withContext(Dispatchers.Main) { if (_binding != null) loadBooks() }
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.action_cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun shareBook(book: Book) {
|
||||
runCatching { BookSharing.shareDownloadedBook(requireContext(), book) }
|
||||
.onFailure { exception ->
|
||||
@@ -381,7 +546,13 @@ class BookshelfFragment : Fragment() {
|
||||
|
||||
private fun deleteBook(book: Book) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = runCatching { app.bookRepository.deleteBook(book) }
|
||||
val result = runCatching {
|
||||
app.audioBookRepository.getBySourceBookId(book.id)?.let { audioBook ->
|
||||
AudioBookGenerationService.cancel(requireContext(), audioBook.id)
|
||||
app.audioBookRepository.delete(audioBook)
|
||||
}
|
||||
app.bookRepository.deleteBook(book)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (_binding == null) {
|
||||
return@withContext
|
||||
|
||||
@@ -47,8 +47,6 @@ import com.aletheia.app.model.Book
|
||||
import com.aletheia.app.model.ReaderChapterItem
|
||||
import com.aletheia.app.model.ReadingBookmark
|
||||
import com.aletheia.app.model.ReadingNote
|
||||
import com.aletheia.app.ui.reader.tts.ReaderSpeechController
|
||||
import com.aletheia.app.ui.reader.tts.ReaderSpeechSettingsDialog
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import java.io.File
|
||||
import kotlin.math.abs
|
||||
@@ -67,7 +65,6 @@ import org.json.JSONObject
|
||||
class ReaderActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityReaderBinding
|
||||
private lateinit var webController: ReaderWebController
|
||||
private lateinit var speechController: ReaderSpeechController
|
||||
private val app by lazy { application as AletheiaApplication }
|
||||
private val paginationCache by lazy { ReaderPaginationCache(this) }
|
||||
|
||||
@@ -131,13 +128,6 @@ class ReaderActivity : AppCompatActivity() {
|
||||
applyNativePreferences()
|
||||
|
||||
webController = ReaderWebController(this, binding.readerWebView, ::handleReaderEvent)
|
||||
speechController = ReaderSpeechController(
|
||||
context = this,
|
||||
scope = lifecycleScope,
|
||||
reader = webController,
|
||||
onStateChanged = ::renderSpeechState,
|
||||
onMessage = { message -> Toast.makeText(this, message, Toast.LENGTH_LONG).show() }
|
||||
)
|
||||
applyRequestedOrientation()
|
||||
showLoading("Подготавливаю книгу…")
|
||||
|
||||
@@ -190,7 +180,6 @@ class ReaderActivity : AppCompatActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
if (::speechController.isInitialized) speechController.destroy()
|
||||
if (::webController.isInitialized) webController.destroy()
|
||||
} finally {
|
||||
super.onDestroy()
|
||||
@@ -336,21 +325,6 @@ class ReaderActivity : AppCompatActivity() {
|
||||
false
|
||||
}
|
||||
binding.readerBackButton.setOnClickListener { finishReader() }
|
||||
binding.readerVoiceButton.setOnClickListener {
|
||||
if (!bookReady) {
|
||||
Toast.makeText(this, "Дождитесь загрузки книги", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
speechController.toggle()
|
||||
}
|
||||
}
|
||||
binding.readerVoiceButton.setOnLongClickListener {
|
||||
if (::speechController.isInitialized) {
|
||||
ReaderSpeechSettingsDialog.show(this, speechController)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
binding.readerSettingsButton.setOnClickListener { openSettings() }
|
||||
binding.readerContentsButton.setOnClickListener { openContents() }
|
||||
binding.readerBookmarkButton.setOnClickListener { toggleBookmark() }
|
||||
@@ -513,17 +487,6 @@ class ReaderActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderSpeechState(state: ReaderSpeechController.State) {
|
||||
val active = state == ReaderSpeechController.State.PLAYING ||
|
||||
state == ReaderSpeechController.State.INITIALIZING
|
||||
binding.readerVoiceButton.isSelected = active
|
||||
binding.readerVoiceButton.contentDescription = if (active) {
|
||||
"Приостановить озвучивание"
|
||||
} else {
|
||||
"Слушать книгу. Удерживайте для настройки голоса, темпа и пауз"
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadBook(bookId: Long) {
|
||||
lifecycleScope.launch {
|
||||
val data = withContext(Dispatchers.IO) {
|
||||
@@ -1144,7 +1107,6 @@ class ReaderActivity : AppCompatActivity() {
|
||||
|
||||
binding.readerBackButton.imageTintList = ColorStateList.valueOf(palette.backIcon)
|
||||
listOf(
|
||||
binding.readerVoiceButton,
|
||||
binding.readerSearchButton,
|
||||
binding.readerSettingsButton,
|
||||
binding.readerContentsButton,
|
||||
|
||||
@@ -111,10 +111,11 @@ class ReaderWebController(
|
||||
|
||||
override fun onRenderProcessGone(view: WebView?, detail: RenderProcessGoneDetail?): Boolean {
|
||||
renderProcessGone = true
|
||||
val rendererCrashed = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && detail?.didCrash() == true
|
||||
onEvent(
|
||||
ReaderEvent.Error(
|
||||
stage = "renderer",
|
||||
code = if (detail?.didCrash() == true) "RenderProcessCrash" else "RenderProcessGone",
|
||||
code = if (rendererCrashed) "RenderProcessCrash" else "RenderProcessGone",
|
||||
message = "Процесс отображения книги был перезапущен",
|
||||
recoverable = true
|
||||
)
|
||||
@@ -212,24 +213,6 @@ class ReaderWebController(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSpeechPage(maxCharacters: Int): ReaderSpeechPage? =
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
withTimeoutOrNull(JS_TIMEOUT_MS) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val limit = maxCharacters.coerceIn(200, MAX_SPEECH_PAGE_CHARACTERS)
|
||||
webView.evaluateJavascript(
|
||||
"window.ReaderV2 && window.ReaderV2.getSpeechPageJson ? " +
|
||||
"window.ReaderV2.getSpeechPageJson($limit) : null"
|
||||
) { value ->
|
||||
val result = decodeJavascriptString(value)
|
||||
?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
?.let(ReaderSpeechPage::fromJson)
|
||||
if (continuation.isActive) continuation.resume(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reload(): Boolean {
|
||||
if (renderProcessGone) return false
|
||||
shellReady = false
|
||||
@@ -345,7 +328,6 @@ class ReaderWebController(
|
||||
private const val READER_URL = "https://$APP_ASSET_HOST/assets/reader_v2/index.html"
|
||||
private const val BOOK_URL = "https://$APP_ASSET_HOST/book/$BOOK_RESOURCE_NAME"
|
||||
private const val JS_TIMEOUT_MS = 5_000L
|
||||
private const val MAX_SPEECH_PAGE_CHARACTERS = 3_500
|
||||
|
||||
private fun emptyResponse(status: Int, reason: String): WebResourceResponse =
|
||||
WebResourceResponse(
|
||||
@@ -397,32 +379,3 @@ class ReaderWebController(
|
||||
private val FB2_LEGACY_LOCATOR = Regex("^fb2:[^:]+:\\d+(?::\\d+)?$")
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderSpeechPage(
|
||||
val text: String,
|
||||
val locator: String?,
|
||||
val canAdvance: Boolean,
|
||||
val source: String,
|
||||
val pageCharacters: Int,
|
||||
val extendedCharacters: Int
|
||||
) {
|
||||
companion object {
|
||||
internal fun fromJson(json: JSONObject): ReaderSpeechPage? {
|
||||
val text = json.optString("text").trim()
|
||||
if (text.isBlank()) return null
|
||||
val locator = when (val value = json.opt("locator")) {
|
||||
is JSONObject -> value.toString()
|
||||
is String -> value.takeIf(String::isNotBlank)
|
||||
else -> null
|
||||
}
|
||||
return ReaderSpeechPage(
|
||||
text = text,
|
||||
locator = locator,
|
||||
canAdvance = json.optBoolean("canAdvance", true),
|
||||
source = json.optString("source", "unknown"),
|
||||
pageCharacters = json.optInt("pageCharacters", text.length),
|
||||
extendedCharacters = json.optInt("extendedCharacters", 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
package com.aletheia.app.ui.reader.tts
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.aletheia.app.ui.reader.ReaderSpeechPage
|
||||
import com.aletheia.app.ui.reader.ReaderWebController
|
||||
import java.util.concurrent.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ReaderSpeechController(
|
||||
context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val reader: ReaderWebController,
|
||||
private val onStateChanged: (State) -> Unit,
|
||||
private val onMessage: (String) -> Unit
|
||||
) {
|
||||
enum class State { IDLE, INITIALIZING, PLAYING, PAUSED, ERROR }
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val preferences = appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
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
|
||||
|
||||
val rate: Float
|
||||
get() = preferences.getFloat(KEY_RATE, DEFAULT_RATE).coerceIn(MIN_RATE, MAX_RATE)
|
||||
|
||||
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()
|
||||
State.IDLE, State.PAUSED, State.ERROR -> start()
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (!initializationComplete) {
|
||||
resumeAfterInitialization = true
|
||||
initialize()
|
||||
return
|
||||
}
|
||||
currentPage = null
|
||||
lastSpokenSignature = null
|
||||
updateState(State.PLAYING)
|
||||
speakCurrentPage(reusePausedPage = false)
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
resumeAfterInitialization = false
|
||||
playbackJob?.cancel()
|
||||
playbackJob = null
|
||||
engine.stop()
|
||||
updateState(State.PAUSED)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
resumeAfterInitialization = false
|
||||
playbackJob?.cancel()
|
||||
playbackJob = null
|
||||
engine.stop()
|
||||
currentPage = null
|
||||
lastSpokenSignature = null
|
||||
updateState(State.IDLE)
|
||||
}
|
||||
|
||||
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()
|
||||
if (state == State.PLAYING) {
|
||||
engine.stop()
|
||||
speakCurrentPage(reusePausedPage = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
initializationJob?.cancel()
|
||||
playbackJob?.cancel()
|
||||
initializationJob = null
|
||||
playbackJob = null
|
||||
engine.destroy()
|
||||
initializationComplete = false
|
||||
}
|
||||
|
||||
private fun initialize() {
|
||||
if (initializationJob?.isActive == true) return
|
||||
updateState(State.INITIALIZING)
|
||||
initializationJob = scope.launch {
|
||||
try {
|
||||
engine.initialize()
|
||||
initializationComplete = true
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun speakCurrentPage(reusePausedPage: Boolean) {
|
||||
playbackJob?.cancel()
|
||||
playbackJob = scope.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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun advanceAfterPage() {
|
||||
if (state != State.PLAYING) return
|
||||
val page = currentPage ?: return
|
||||
if (!page.canAdvance) {
|
||||
stop()
|
||||
onMessage("Книга прочитана до конца")
|
||||
return
|
||||
}
|
||||
currentPage = null
|
||||
reader.next()
|
||||
delay(PAGE_TURN_DELAY_MS)
|
||||
if (state == State.PLAYING) speakCurrentPage(reusePausedPage = false)
|
||||
}
|
||||
|
||||
private fun fail(message: String) {
|
||||
playbackJob?.cancel()
|
||||
playbackJob = null
|
||||
engine.stop()
|
||||
updateState(State.ERROR)
|
||||
onMessage(message)
|
||||
}
|
||||
|
||||
private fun updateState(next: State) {
|
||||
Log.d(TAG, "State: $state -> $next")
|
||||
state = next
|
||||
onStateChanged(next)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object ReaderSpeechSettingsDialog {
|
||||
fun show(context: Context, controller: ReaderSpeechController) {
|
||||
val density = context.resources.displayMetrics.density
|
||||
val content = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val horizontal = (24 * density).roundToInt()
|
||||
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 = "Темп чтения",
|
||||
value = controller.rate,
|
||||
min = ReaderSpeechController.MIN_RATE,
|
||||
max = ReaderSpeechController.MAX_RATE,
|
||||
valueView = rateValue,
|
||||
parent = content
|
||||
)
|
||||
val pitchValue = TextView(context)
|
||||
val pitch = slider(
|
||||
context = context,
|
||||
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("Все параметры применяются к встроенному офлайн-голосу. Настройки меняются плавно.")
|
||||
.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(
|
||||
pause.progress,
|
||||
ReaderSpeechController.MIN_PAUSE_SCALE,
|
||||
ReaderSpeechController.MAX_PAUSE_SCALE
|
||||
),
|
||||
progressToValue(
|
||||
articulation.progress,
|
||||
ReaderSpeechController.MIN_ARTICULATION_SCALE,
|
||||
ReaderSpeechController.MAX_ARTICULATION_SCALE
|
||||
),
|
||||
voice.selectedItemPosition
|
||||
)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun slider(
|
||||
context: Context,
|
||||
label: String,
|
||||
value: Float,
|
||||
min: Float,
|
||||
max: Float,
|
||||
valueView: TextView,
|
||||
parent: LinearLayout
|
||||
): SeekBar {
|
||||
val title = TextView(context).apply {
|
||||
text = label
|
||||
textSize = 15f
|
||||
setPadding(0, 16, 0, 0)
|
||||
}
|
||||
val seekBar = SeekBar(context).apply {
|
||||
this.max = SLIDER_STEPS
|
||||
progress = valueToProgress(value, min, max)
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
fun render(progress: Int) {
|
||||
valueView.text = String.format(
|
||||
Locale.getDefault(),
|
||||
"%.2f×",
|
||||
progressToValue(progress, min, max)
|
||||
)
|
||||
}
|
||||
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) = render(progress)
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
|
||||
})
|
||||
render(seekBar.progress)
|
||||
parent.addView(title)
|
||||
parent.addView(valueView)
|
||||
parent.addView(seekBar)
|
||||
return seekBar
|
||||
}
|
||||
|
||||
private fun valueToProgress(value: Float, min: Float, max: Float): Int =
|
||||
(((value.coerceIn(min, max) - min) / (max - min)) * SLIDER_STEPS).roundToInt()
|
||||
|
||||
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 = 300
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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("квадриллион", "квадриллиона", "квадриллионов")
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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(
|
||||
"г", "гг", "ул", "стр", "рис", "им", "т", "д", "п", "н", "э", "е", "к",
|
||||
"др", "см", "руб", "коп", "тыс", "млн", "млрд"
|
||||
)
|
||||
}
|
||||
@@ -1,708 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -218,12 +218,11 @@ class SettingsFragment : Fragment() {
|
||||
val binding = _binding ?: return
|
||||
val url = binding.qbooksUrlInput.text?.toString().orEmpty().trim()
|
||||
val validation = QBooksUrlPolicy.validate(url)
|
||||
val host = validation.host ?: url
|
||||
val (text, color) = when {
|
||||
url.isBlank() -> getString(R.string.settings_overview_qbooks_missing) to Color.parseColor("#6E5648")
|
||||
!validation.isAllowed -> getString(R.string.settings_overview_qbooks_invalid) to Color.parseColor("#B64932")
|
||||
validation.scheme == "https" -> getString(R.string.settings_overview_qbooks_https, host) to Color.parseColor("#2F7D5A")
|
||||
validation.scheme == "http" -> getString(R.string.settings_overview_qbooks_local_http, host) to Color.parseColor("#9A672B")
|
||||
validation.scheme == "https" -> getString(R.string.settings_overview_qbooks_https) to Color.parseColor("#2F7D5A")
|
||||
validation.scheme == "http" -> getString(R.string.settings_overview_qbooks_local_http) to Color.parseColor("#9A672B")
|
||||
else -> getString(R.string.settings_overview_qbooks_invalid) to Color.parseColor("#B64932")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||
<solid android:color="#252525" />
|
||||
</shape>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||
<solid android:color="#F0F0F0" />
|
||||
</shape>
|
||||
@@ -0,0 +1,204 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/white"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingTop="18dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingBottom="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/player_back_button"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="@drawable/bg_player_soft_circle"
|
||||
android:contentDescription="Назад"
|
||||
android:padding="14dp"
|
||||
android:rotation="-90"
|
||||
android:src="@drawable/ic_reader_v2_chevron_down" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="14dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="#222222"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_author"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="#999999"
|
||||
android:textSize="17sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_more_button"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="@drawable/bg_player_soft_circle"
|
||||
android:gravity="center"
|
||||
android:text="⋮"
|
||||
android:textColor="#252525"
|
||||
android:textSize="28sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_cover"
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="280dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="34dp"
|
||||
android:background="@drawable/bg_cover_placeholder"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_remaining_summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="22dp"
|
||||
android:gravity="center"
|
||||
android:textColor="#A0A0A0"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="92dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_speed_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="1x"
|
||||
android:textColor="#222222"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_rewind_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="↶\n15"
|
||||
android:textColor="#222222"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_play_button"
|
||||
android:layout_width="76dp"
|
||||
android:layout_height="76dp"
|
||||
android:background="@drawable/bg_player_circle"
|
||||
android:gravity="center"
|
||||
android:paddingStart="5dp"
|
||||
android:text="▶"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="34sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_forward_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="↷\n30"
|
||||
android:textColor="#222222"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_sleep_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="◴"
|
||||
android:textColor="#222222"
|
||||
android:textSize="28sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_chapter"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:text="Вся книга"
|
||||
android:textColor="#222222"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/player_seek_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:max="1000"
|
||||
android:progressTint="#303030"
|
||||
android:thumbTint="#303030" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_elapsed"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="00:00"
|
||||
android:textColor="#9A9A9A"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_remaining"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="-00:00"
|
||||
android:textColor="#9A9A9A"
|
||||
android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/player_read_button"
|
||||
android:layout_width="132dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="30dp"
|
||||
android:backgroundTint="#252525"
|
||||
android:text="Читать"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="17sp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -192,16 +192,6 @@
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/reader_voice_button"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/reader_v2_voice"
|
||||
android:padding="12dp"
|
||||
android:src="@drawable/ic_reader_v2_voice" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/reader_search_button"
|
||||
android:layout_width="48dp"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
android:layout_height="28dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_reader_v2_bookmark"
|
||||
android:tint="@color/reader_v2_accent" />
|
||||
app:tint="@color/reader_v2_accent" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_reader_v2_quote"
|
||||
android:tint="@color/reader_v2_accent" />
|
||||
app:tint="@color/reader_v2_accent" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/action_create_audiobook"
|
||||
android:title="Создать аудиокнигу" />
|
||||
<item
|
||||
android:id="@+id/action_share_book"
|
||||
android:title="@string/action_share" />
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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 году"))
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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