feat: add remote audiobook generation
This commit is contained in:
+23
-7
@@ -1,7 +1,15 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
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 {
|
android {
|
||||||
namespace = "com.aletheia.app"
|
namespace = "com.aletheia.app"
|
||||||
@@ -11,8 +19,15 @@ android {
|
|||||||
applicationId = "com.aletheia.app"
|
applicationId = "com.aletheia.app"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 45
|
versionCode = 48
|
||||||
versionName = "2.34"
|
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"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@@ -49,6 +64,7 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
@@ -57,9 +73,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
androidResources {
|
|
||||||
noCompress += "onnx"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -74,7 +87,10 @@ dependencies {
|
|||||||
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||||
implementation("androidx.webkit:webkit:1.12.1")
|
implementation("androidx.webkit:webkit:1.12.1")
|
||||||
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.27.0")
|
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
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 the manifest-declared Argus package installer callback constructable by Android.
|
||||||
-keep class xyz.kusoft.argusupdater.ArgusPackageInstallerStatusReceiver { public <init>(); }
|
-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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<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
|
<application
|
||||||
android:name=".AletheiaApplication"
|
android:name=".AletheiaApplication"
|
||||||
@@ -25,12 +28,25 @@
|
|||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</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
|
<activity
|
||||||
android:name=".ui.reader.ReaderActivity"
|
android:name=".ui.reader.ReaderActivity"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize"
|
android:configChanges="orientation|screenSize|smallestScreenSize"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@style/Theme.Aletheia"
|
android:theme="@style/Theme.Aletheia"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.audiobook.AudioBookPlayerActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.Aletheia" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.qbooks.BookDetailActivity"
|
android:name=".ui.qbooks.BookDetailActivity"
|
||||||
android:exported="false"
|
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 android.app.Application
|
||||||
import com.aletheia.app.data.AppDatabaseHelper
|
import com.aletheia.app.data.AppDatabaseHelper
|
||||||
|
import com.aletheia.app.data.AudioBookRepository
|
||||||
import com.aletheia.app.data.BookParserService
|
import com.aletheia.app.data.BookParserService
|
||||||
import com.aletheia.app.data.BookRepository
|
import com.aletheia.app.data.BookRepository
|
||||||
import com.aletheia.app.data.CatalogCache
|
import com.aletheia.app.data.CatalogCache
|
||||||
@@ -35,6 +36,9 @@ class AletheiaApplication : Application() {
|
|||||||
lateinit var bookRepository: BookRepository
|
lateinit var bookRepository: BookRepository
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
lateinit var audioBookRepository: AudioBookRepository
|
||||||
|
private set
|
||||||
|
|
||||||
lateinit var qBooksService: QBooksService
|
lateinit var qBooksService: QBooksService
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -53,6 +57,7 @@ class AletheiaApplication : Application() {
|
|||||||
settingsRepository = SettingsRepository(this, databaseHelper)
|
settingsRepository = SettingsRepository(this, databaseHelper)
|
||||||
bookParserService = BookParserService(this)
|
bookParserService = BookParserService(this)
|
||||||
bookRepository = BookRepository(databaseHelper, bookParserService, settingsRepository)
|
bookRepository = BookRepository(databaseHelper, bookParserService, settingsRepository)
|
||||||
|
audioBookRepository = AudioBookRepository(this, databaseHelper)
|
||||||
catalogCache = CatalogCache(this)
|
catalogCache = CatalogCache(this)
|
||||||
qBooksService = QBooksService(settingsRepository, catalogCache)
|
qBooksService = QBooksService(settingsRepository, catalogCache)
|
||||||
appUpdateManager = ArgusUpdateManager(this)
|
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.Cursor
|
||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
|
import com.aletheia.app.model.AudioBook
|
||||||
import com.aletheia.app.model.Book
|
import com.aletheia.app.model.Book
|
||||||
import com.aletheia.app.model.ReadingBookmark
|
import com.aletheia.app.model.ReadingBookmark
|
||||||
import com.aletheia.app.model.ReadingNote
|
import com.aletheia.app.model.ReadingNote
|
||||||
@@ -61,6 +62,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
|||||||
|
|
||||||
createBookmarksTable(db)
|
createBookmarksTable(db)
|
||||||
createNotesTable(db)
|
createNotesTable(db)
|
||||||
|
createAudioBooksTable(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
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'")
|
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(
|
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_progress", "book_id = ?", arrayOf(book.id.toString()))
|
||||||
writableDatabase.delete("reading_bookmarks", "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("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.delete("books", "id = ?", arrayOf(book.id.toString()))
|
||||||
writableDatabase.setTransactionSuccessful()
|
writableDatabase.setTransactionSuccessful()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -153,6 +162,34 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
|||||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
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> {
|
fun getSettings(keys: Collection<String>): Map<String, String> {
|
||||||
if (keys.isEmpty()) return emptyMap()
|
if (keys.isEmpty()) return emptyMap()
|
||||||
val orderedKeys = keys.distinct()
|
val orderedKeys = keys.distinct()
|
||||||
@@ -368,6 +405,24 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
|||||||
put("remote_id", book.remoteId)
|
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(
|
private fun cursorToBook(cursor: Cursor) = Book(
|
||||||
id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
|
id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
|
||||||
title = cursor.getString(cursor.getColumnIndexOrThrow("title")),
|
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"))
|
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(
|
private fun cursorToBookmark(cursor: Cursor) = ReadingBookmark(
|
||||||
id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
|
id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
|
||||||
bookId = cursor.getLong(cursor.getColumnIndexOrThrow("book_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 {
|
private fun hasColumn(db: SQLiteDatabase, tableName: String, columnName: String): Boolean {
|
||||||
db.rawQuery("PRAGMA table_info($tableName)", null).use { cursor ->
|
db.rawQuery("PRAGMA table_info($tableName)", null).use { cursor ->
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
@@ -487,7 +592,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val DATABASE_NAME = "aletheia.db3"
|
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
|
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.R
|
||||||
import com.aletheia.app.databinding.ItemBookBinding
|
import com.aletheia.app.databinding.ItemBookBinding
|
||||||
import com.aletheia.app.databinding.ItemShelfAddBinding
|
import com.aletheia.app.databinding.ItemShelfAddBinding
|
||||||
|
import com.aletheia.app.model.AudioBook
|
||||||
import com.aletheia.app.model.Book
|
import com.aletheia.app.model.Book
|
||||||
import com.aletheia.app.util.setBookCover
|
import com.aletheia.app.util.setBookCover
|
||||||
|
|
||||||
class BooksAdapter(
|
class BooksAdapter(
|
||||||
private val onBookClicked: (Book) -> Unit,
|
private val onBookClicked: (Book) -> Unit,
|
||||||
|
private val onAudioBookClicked: (AudioBook) -> Unit = {},
|
||||||
private val onAddBookClicked: () -> Unit,
|
private val onAddBookClicked: () -> Unit,
|
||||||
private val onBookLongPressed: ((Book) -> Unit)? = null,
|
private val onBookLongPressed: ((Book) -> Unit)? = null,
|
||||||
private val onBookActionsClicked: ((View, Book) -> Unit)? = null,
|
private val onBookActionsClicked: ((View, Book) -> Unit)? = null,
|
||||||
|
private val onAudioBookActionsClicked: ((View, AudioBook) -> Unit)? = null,
|
||||||
private val includeAddItem: Boolean = true
|
private val includeAddItem: Boolean = true
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
private val items = mutableListOf<Book>()
|
private val items = mutableListOf<ShelfItem>()
|
||||||
|
|
||||||
init {
|
init { setHasStableIds(true) }
|
||||||
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>) {
|
fun submitContent(books: List<Book>, audioBooks: List<Pair<AudioBook, ByteArray?>>) {
|
||||||
val oldItems = items.toList()
|
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() {
|
val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||||
override fun getOldListSize(): Int = oldItems.shelfItemCount()
|
override fun getOldListSize() = old.itemCount()
|
||||||
override fun getNewListSize(): Int = books.shelfItemCount()
|
override fun getNewListSize() = next.itemCount()
|
||||||
|
override fun areItemsTheSame(oldPosition: Int, newPosition: Int): Boolean {
|
||||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
val oldItem = old.getOrNull(oldPosition)
|
||||||
val oldIsAddItem = oldItemPosition >= oldItems.size
|
val newItem = next.getOrNull(newPosition)
|
||||||
val newIsAddItem = newItemPosition >= books.size
|
return if (oldItem == null || newItem == null) oldItem == newItem else oldItem.stableId == newItem.stableId
|
||||||
return when {
|
|
||||||
oldIsAddItem && newIsAddItem -> true
|
|
||||||
oldIsAddItem || newIsAddItem -> false
|
|
||||||
else -> oldItems[oldItemPosition].id == books[newItemPosition].id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
override fun areContentsTheSame(oldPosition: Int, newPosition: Int): Boolean {
|
||||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
val oldItem = old.getOrNull(oldPosition)
|
||||||
val oldIsAddItem = oldItemPosition >= oldItems.size
|
val newItem = next.getOrNull(newPosition)
|
||||||
val newIsAddItem = newItemPosition >= books.size
|
return if (oldItem == null || newItem == null) oldItem == newItem else oldItem.sameVisibleContent(newItem)
|
||||||
return when {
|
|
||||||
oldIsAddItem && newIsAddItem -> true
|
|
||||||
oldIsAddItem || newIsAddItem -> false
|
|
||||||
else -> oldItems[oldItemPosition].hasSameVisibleContent(books[newItemPosition])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
items.clear()
|
items.clear()
|
||||||
items.addAll(books)
|
items.addAll(next)
|
||||||
diff.dispatchUpdatesTo(this)
|
diff.dispatchUpdatesTo(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateBook(book: Book) {
|
override fun getItemViewType(position: Int) = if (position < items.size) VIEW_TYPE_BOOK else VIEW_TYPE_ADD
|
||||||
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 onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
val inflater = LayoutInflater.from(parent.context)
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
return when (viewType) {
|
return if (viewType == VIEW_TYPE_BOOK) {
|
||||||
VIEW_TYPE_BOOK -> BookViewHolder(ItemBookBinding.inflate(inflater, parent, false))
|
BookViewHolder(ItemBookBinding.inflate(inflater, parent, false))
|
||||||
VIEW_TYPE_ADD -> AddBookViewHolder(ItemShelfAddBinding.inflate(inflater, parent, false))
|
} else {
|
||||||
else -> error("Unsupported bookshelf item view type: $viewType")
|
AddBookViewHolder(ItemShelfAddBinding.inflate(inflater, parent, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,72 +78,88 @@ class BooksAdapter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int = items.shelfItemCount()
|
override fun getItemCount() = items.itemCount()
|
||||||
|
override fun getItemId(position: Int) = items.getOrNull(position)?.stableId ?: ADD_ITEM_ID
|
||||||
override fun getItemId(position: Int): Long =
|
|
||||||
if (position < items.size) items[position].id else ADD_ITEM_ID
|
|
||||||
|
|
||||||
private inner class BookViewHolder(private val binding: ItemBookBinding) : RecyclerView.ViewHolder(binding.root) {
|
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
|
val context = binding.root.context
|
||||||
binding.bookTitle.text = book.title
|
when (item) {
|
||||||
binding.bookAuthor.text = book.author
|
is ShelfItem.TextBook -> {
|
||||||
binding.bookProgressText.text = book.progressText
|
val book = item.book
|
||||||
binding.bookProgressBar.progress = (book.readingProgress * 100).toInt()
|
binding.bookTitle.text = book.title
|
||||||
binding.bookCover.setBookCover(book.coverImage, R.drawable.default_cover)
|
binding.bookAuthor.text = book.author
|
||||||
binding.root.contentDescription = context.getString(
|
binding.bookProgressText.text = book.progressText
|
||||||
R.string.a11y_book_card,
|
binding.bookProgressBar.progress = (book.readingProgress * 100).toInt()
|
||||||
book.title,
|
binding.bookCover.setBookCover(book.coverImage, R.drawable.default_cover)
|
||||||
book.author,
|
binding.root.contentDescription = context.getString(
|
||||||
book.progressText
|
R.string.a11y_book_card, book.title, book.author, book.progressText
|
||||||
)
|
)
|
||||||
binding.root.setOnClickListener { onBookClicked(book) }
|
binding.root.setOnClickListener { onBookClicked(book) }
|
||||||
if (onBookLongPressed == null) {
|
binding.root.setOnLongClickListener(if (onBookLongPressed == null) null else View.OnLongClickListener {
|
||||||
binding.root.setOnLongClickListener(null)
|
onBookLongPressed.invoke(book)
|
||||||
binding.root.isLongClickable = false
|
true
|
||||||
} else {
|
})
|
||||||
binding.root.setOnLongClickListener {
|
bindActions { anchor -> onBookActionsClicked?.invoke(anchor, book) }
|
||||||
onBookLongPressed.invoke(book)
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
}
|
is ShelfItem.Audio -> {
|
||||||
if (onBookActionsClicked == null) {
|
val audio = item.audioBook
|
||||||
binding.bookActions.visibility = View.GONE
|
val progress = when {
|
||||||
binding.bookActions.setOnClickListener(null)
|
audio.status == AudioBook.Status.GENERATING || audio.status == AudioBook.Status.QUEUED ->
|
||||||
} else {
|
audio.progressPercent
|
||||||
binding.bookActions.visibility = View.VISIBLE
|
audio.durationMs > 0 -> ((audio.positionMs * 100L) / audio.durationMs).toInt().coerceIn(0, 100)
|
||||||
binding.bookActions.contentDescription = context.getString(R.string.a11y_book_actions, book.title)
|
else -> 0
|
||||||
binding.bookActions.setOnClickListener { anchor ->
|
}
|
||||||
onBookActionsClicked.invoke(anchor, book)
|
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) {
|
private inner class AddBookViewHolder(private val binding: ItemShelfAddBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind() {
|
fun bind() { binding.root.setOnClickListener { onAddBookClicked() } }
|
||||||
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 =
|
private fun List<ShelfItem>.itemCount() = if (isEmpty()) 0 else size + if (includeAddItem) 1 else 0
|
||||||
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 companion object {
|
private companion object {
|
||||||
const val VIEW_TYPE_BOOK = 1
|
const val VIEW_TYPE_BOOK = 1
|
||||||
const val VIEW_TYPE_ADD = 2
|
const val VIEW_TYPE_ADD = 2
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.aletheia.app.ui.books
|
package com.aletheia.app.ui.books
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
@@ -23,10 +27,13 @@ import com.aletheia.app.R
|
|||||||
import com.aletheia.app.data.Result
|
import com.aletheia.app.data.Result
|
||||||
import com.aletheia.app.databinding.FragmentBookshelfBinding
|
import com.aletheia.app.databinding.FragmentBookshelfBinding
|
||||||
import com.aletheia.app.model.Book
|
import com.aletheia.app.model.Book
|
||||||
|
import com.aletheia.app.model.AudioBook
|
||||||
import com.aletheia.app.model.QBooksBook
|
import com.aletheia.app.model.QBooksBook
|
||||||
import com.aletheia.app.ui.main.MainActivity
|
import com.aletheia.app.ui.main.MainActivity
|
||||||
import com.aletheia.app.ui.qbooks.BookDetailActivity
|
import com.aletheia.app.ui.qbooks.BookDetailActivity
|
||||||
import com.aletheia.app.ui.reader.ReaderActivity
|
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.BookSharing
|
||||||
import com.aletheia.app.util.setBookCover
|
import com.aletheia.app.util.setBookCover
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
@@ -46,11 +53,22 @@ class BookshelfFragment : Fragment() {
|
|||||||
private lateinit var qBooksShelfAdapter: QBooksShelfAdapter
|
private lateinit var qBooksShelfAdapter: QBooksShelfAdapter
|
||||||
private var currentBooks: List<Book> = emptyList()
|
private var currentBooks: List<Book> = emptyList()
|
||||||
private var visibleBooks: 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 var continueReadingBook: Book? = null
|
||||||
private val metadataJobs = mutableListOf<Job>()
|
private val metadataJobs = mutableListOf<Job>()
|
||||||
private var loadBooksJob: Job? = null
|
private var loadBooksJob: Job? = null
|
||||||
private var libraryFilter = LibraryFilter.ALL
|
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 ->
|
private val importBookLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
return@registerForActivityResult
|
return@registerForActivityResult
|
||||||
@@ -83,9 +101,11 @@ class BookshelfFragment : Fragment() {
|
|||||||
|
|
||||||
booksAdapter = BooksAdapter(
|
booksAdapter = BooksAdapter(
|
||||||
onBookClicked = ::openBook,
|
onBookClicked = ::openBook,
|
||||||
|
onAudioBookClicked = ::openAudioBook,
|
||||||
onAddBookClicked = ::launchImport,
|
onAddBookClicked = ::launchImport,
|
||||||
onBookLongPressed = ::confirmDeleteBook,
|
onBookLongPressed = ::confirmDeleteBook,
|
||||||
onBookActionsClicked = ::showBookActions
|
onBookActionsClicked = ::showBookActions,
|
||||||
|
onAudioBookActionsClicked = ::showAudioBookActions
|
||||||
)
|
)
|
||||||
binding.booksRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
|
binding.booksRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
|
||||||
binding.booksRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8)))
|
binding.booksRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8)))
|
||||||
@@ -150,6 +170,20 @@ class BookshelfFragment : Fragment() {
|
|||||||
loadBooks()
|
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) {
|
override fun onHiddenChanged(hidden: Boolean) {
|
||||||
super.onHiddenChanged(hidden)
|
super.onHiddenChanged(hidden)
|
||||||
if (!hidden && _binding != null) {
|
if (!hidden && _binding != null) {
|
||||||
@@ -169,14 +203,17 @@ class BookshelfFragment : Fragment() {
|
|||||||
private fun loadBooks() {
|
private fun loadBooks() {
|
||||||
loadBooksJob?.cancel()
|
loadBooksJob?.cancel()
|
||||||
loadBooksJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
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) {
|
withContext(Dispatchers.Main) {
|
||||||
if (_binding == null) {
|
if (_binding == null) {
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
result.onSuccess { books ->
|
result.onSuccess { (books, audioBooks) ->
|
||||||
currentBooks = books
|
currentBooks = books
|
||||||
|
currentAudioBooks = audioBooks
|
||||||
renderFilteredBooks()
|
renderFilteredBooks()
|
||||||
enrichMissingMetadata(books.take(METADATA_PREFETCH_LIMIT))
|
enrichMissingMetadata(books.take(METADATA_PREFETCH_LIMIT))
|
||||||
}.onFailure { exception ->
|
}.onFailure { exception ->
|
||||||
@@ -205,15 +242,26 @@ class BookshelfFragment : Fragment() {
|
|||||||
private fun renderFilteredBooks() {
|
private fun renderFilteredBooks() {
|
||||||
val query = binding.booksSearchInput.text?.toString().orEmpty().trim()
|
val query = binding.booksSearchInput.text?.toString().orEmpty().trim()
|
||||||
visibleBooks = filterBooks(currentBooks, query)
|
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(
|
renderLibraryState(
|
||||||
allBooks = currentBooks,
|
allBooks = currentBooks,
|
||||||
displayedBooks = visibleBooks,
|
displayedBooks = visibleBooks,
|
||||||
|
displayedAudioBooks = visibleAudioBooks,
|
||||||
query = query
|
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
|
val isFiltered = query.isNotBlank() || libraryFilter != LibraryFilter.ALL
|
||||||
continueReadingBook = if (isFiltered) {
|
continueReadingBook = if (isFiltered) {
|
||||||
null
|
null
|
||||||
@@ -225,8 +273,8 @@ class BookshelfFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val isLibraryEmpty = allBooks.isEmpty()
|
val isLibraryEmpty = allBooks.isEmpty()
|
||||||
val isSearchEmpty = !isLibraryEmpty && displayedBooks.isEmpty()
|
val isSearchEmpty = !isLibraryEmpty && displayedBooks.isEmpty() && displayedAudioBooks.isEmpty()
|
||||||
val hasVisibleBooks = displayedBooks.isNotEmpty()
|
val hasVisibleBooks = displayedBooks.isNotEmpty() || displayedAudioBooks.isNotEmpty()
|
||||||
binding.emptyLibraryCard.visibility = if (isLibraryEmpty) View.VISIBLE else View.GONE
|
binding.emptyLibraryCard.visibility = if (isLibraryEmpty) View.VISIBLE else View.GONE
|
||||||
binding.emptySearchCard.visibility = if (isSearchEmpty) 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
|
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 {
|
private enum class LibraryFilter {
|
||||||
ALL,
|
ALL,
|
||||||
READING,
|
READING,
|
||||||
@@ -331,6 +397,21 @@ class BookshelfFragment : Fragment() {
|
|||||||
startActivity(Intent(requireContext(), ReaderActivity::class.java).putExtra(ReaderActivity.EXTRA_BOOK_ID, book.id))
|
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) {
|
private fun openQBooksBookPreview(book: QBooksBook) {
|
||||||
startActivity(BookDetailActivity.createIntent(requireContext(), book))
|
startActivity(BookDetailActivity.createIntent(requireContext(), book))
|
||||||
}
|
}
|
||||||
@@ -348,6 +429,10 @@ class BookshelfFragment : Fragment() {
|
|||||||
inflate(R.menu.book_actions_menu)
|
inflate(R.menu.book_actions_menu)
|
||||||
setOnMenuItemClickListener { item ->
|
setOnMenuItemClickListener { item ->
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
|
R.id.action_create_audiobook -> {
|
||||||
|
confirmCreateAudioBook(book)
|
||||||
|
true
|
||||||
|
}
|
||||||
R.id.action_share_book -> {
|
R.id.action_share_book -> {
|
||||||
shareBook(book)
|
shareBook(book)
|
||||||
true
|
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) {
|
private fun shareBook(book: Book) {
|
||||||
runCatching { BookSharing.shareDownloadedBook(requireContext(), book) }
|
runCatching { BookSharing.shareDownloadedBook(requireContext(), book) }
|
||||||
.onFailure { exception ->
|
.onFailure { exception ->
|
||||||
@@ -381,7 +546,13 @@ class BookshelfFragment : Fragment() {
|
|||||||
|
|
||||||
private fun deleteBook(book: Book) {
|
private fun deleteBook(book: Book) {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
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) {
|
withContext(Dispatchers.Main) {
|
||||||
if (_binding == null) {
|
if (_binding == null) {
|
||||||
return@withContext
|
return@withContext
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ import com.aletheia.app.model.Book
|
|||||||
import com.aletheia.app.model.ReaderChapterItem
|
import com.aletheia.app.model.ReaderChapterItem
|
||||||
import com.aletheia.app.model.ReadingBookmark
|
import com.aletheia.app.model.ReadingBookmark
|
||||||
import com.aletheia.app.model.ReadingNote
|
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 com.google.android.material.button.MaterialButton
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -67,7 +65,6 @@ import org.json.JSONObject
|
|||||||
class ReaderActivity : AppCompatActivity() {
|
class ReaderActivity : AppCompatActivity() {
|
||||||
private lateinit var binding: ActivityReaderBinding
|
private lateinit var binding: ActivityReaderBinding
|
||||||
private lateinit var webController: ReaderWebController
|
private lateinit var webController: ReaderWebController
|
||||||
private lateinit var speechController: ReaderSpeechController
|
|
||||||
private val app by lazy { application as AletheiaApplication }
|
private val app by lazy { application as AletheiaApplication }
|
||||||
private val paginationCache by lazy { ReaderPaginationCache(this) }
|
private val paginationCache by lazy { ReaderPaginationCache(this) }
|
||||||
|
|
||||||
@@ -131,13 +128,6 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
applyNativePreferences()
|
applyNativePreferences()
|
||||||
|
|
||||||
webController = ReaderWebController(this, binding.readerWebView, ::handleReaderEvent)
|
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()
|
applyRequestedOrientation()
|
||||||
showLoading("Подготавливаю книгу…")
|
showLoading("Подготавливаю книгу…")
|
||||||
|
|
||||||
@@ -190,7 +180,6 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
try {
|
try {
|
||||||
if (::speechController.isInitialized) speechController.destroy()
|
|
||||||
if (::webController.isInitialized) webController.destroy()
|
if (::webController.isInitialized) webController.destroy()
|
||||||
} finally {
|
} finally {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
@@ -336,21 +325,6 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
binding.readerBackButton.setOnClickListener { finishReader() }
|
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.readerSettingsButton.setOnClickListener { openSettings() }
|
||||||
binding.readerContentsButton.setOnClickListener { openContents() }
|
binding.readerContentsButton.setOnClickListener { openContents() }
|
||||||
binding.readerBookmarkButton.setOnClickListener { toggleBookmark() }
|
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) {
|
private fun loadBook(bookId: Long) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val data = withContext(Dispatchers.IO) {
|
val data = withContext(Dispatchers.IO) {
|
||||||
@@ -1144,7 +1107,6 @@ class ReaderActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
binding.readerBackButton.imageTintList = ColorStateList.valueOf(palette.backIcon)
|
binding.readerBackButton.imageTintList = ColorStateList.valueOf(palette.backIcon)
|
||||||
listOf(
|
listOf(
|
||||||
binding.readerVoiceButton,
|
|
||||||
binding.readerSearchButton,
|
binding.readerSearchButton,
|
||||||
binding.readerSettingsButton,
|
binding.readerSettingsButton,
|
||||||
binding.readerContentsButton,
|
binding.readerContentsButton,
|
||||||
|
|||||||
@@ -111,10 +111,11 @@ class ReaderWebController(
|
|||||||
|
|
||||||
override fun onRenderProcessGone(view: WebView?, detail: RenderProcessGoneDetail?): Boolean {
|
override fun onRenderProcessGone(view: WebView?, detail: RenderProcessGoneDetail?): Boolean {
|
||||||
renderProcessGone = true
|
renderProcessGone = true
|
||||||
|
val rendererCrashed = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && detail?.didCrash() == true
|
||||||
onEvent(
|
onEvent(
|
||||||
ReaderEvent.Error(
|
ReaderEvent.Error(
|
||||||
stage = "renderer",
|
stage = "renderer",
|
||||||
code = if (detail?.didCrash() == true) "RenderProcessCrash" else "RenderProcessGone",
|
code = if (rendererCrashed) "RenderProcessCrash" else "RenderProcessGone",
|
||||||
message = "Процесс отображения книги был перезапущен",
|
message = "Процесс отображения книги был перезапущен",
|
||||||
recoverable = true
|
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 {
|
fun reload(): Boolean {
|
||||||
if (renderProcessGone) return false
|
if (renderProcessGone) return false
|
||||||
shellReady = 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 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 BOOK_URL = "https://$APP_ASSET_HOST/book/$BOOK_RESOURCE_NAME"
|
||||||
private const val JS_TIMEOUT_MS = 5_000L
|
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 =
|
private fun emptyResponse(status: Int, reason: String): WebResourceResponse =
|
||||||
WebResourceResponse(
|
WebResourceResponse(
|
||||||
@@ -397,32 +379,3 @@ class ReaderWebController(
|
|||||||
private val FB2_LEGACY_LOCATOR = Regex("^fb2:[^:]+:\\d+(?::\\d+)?$")
|
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 binding = _binding ?: return
|
||||||
val url = binding.qbooksUrlInput.text?.toString().orEmpty().trim()
|
val url = binding.qbooksUrlInput.text?.toString().orEmpty().trim()
|
||||||
val validation = QBooksUrlPolicy.validate(url)
|
val validation = QBooksUrlPolicy.validate(url)
|
||||||
val host = validation.host ?: url
|
|
||||||
val (text, color) = when {
|
val (text, color) = when {
|
||||||
url.isBlank() -> getString(R.string.settings_overview_qbooks_missing) to Color.parseColor("#6E5648")
|
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.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 == "https" -> getString(R.string.settings_overview_qbooks_https) to Color.parseColor("#2F7D5A")
|
||||||
validation.scheme == "http" -> getString(R.string.settings_overview_qbooks_local_http, host) to Color.parseColor("#9A672B")
|
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")
|
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_height="1dp"
|
||||||
android:layout_weight="1" />
|
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
|
<ImageButton
|
||||||
android:id="@+id/reader_search_button"
|
android:id="@+id/reader_search_button"
|
||||||
android:layout_width="48dp"
|
android:layout_width="48dp"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
android:layout_height="28dp"
|
android:layout_height="28dp"
|
||||||
android:contentDescription="@null"
|
android:contentDescription="@null"
|
||||||
android:src="@drawable/ic_reader_v2_bookmark"
|
android:src="@drawable/ic_reader_v2_bookmark"
|
||||||
android:tint="@color/reader_v2_accent" />
|
app:tint="@color/reader_v2_accent" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:contentDescription="@null"
|
android:contentDescription="@null"
|
||||||
android:src="@drawable/ic_reader_v2_quote"
|
android:src="@drawable/ic_reader_v2_quote"
|
||||||
android:tint="@color/reader_v2_accent" />
|
app:tint="@color/reader_v2_accent" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_create_audiobook"
|
||||||
|
android:title="Создать аудиокнигу" />
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_share_book"
|
android:id="@+id/action_share_book"
|
||||||
android:title="@string/action_share" />
|
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("Первое предложение. Второе предложение! Третье?")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -328,7 +328,7 @@ if ($RestartArgus) {
|
|||||||
$remoteScript = $remoteScriptLines -join "`n"
|
$remoteScript = $remoteScriptLines -join "`n"
|
||||||
$tempRemoteScript = New-TemporaryFile
|
$tempRemoteScript = New-TemporaryFile
|
||||||
try {
|
try {
|
||||||
Set-Content -LiteralPath $tempRemoteScript -Value $remoteScript -Encoding utf8
|
Set-Content -LiteralPath $tempRemoteScript -Value $remoteScript -Encoding utf8 -NoNewline
|
||||||
Get-Content -LiteralPath $tempRemoteScript -Raw | & ssh $remoteTarget "bash" "-s"
|
Get-Content -LiteralPath $tempRemoteScript -Raw | & ssh $remoteTarget "bash" "-s"
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "ssh exited with code $LASTEXITCODE."
|
throw "ssh exited with code $LASTEXITCODE."
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Aletheia CMP audiobook receiver
|
||||||
|
|
||||||
|
The receiver accepts EPUB/FB2 files, queues one synthesis job at a time, reports progress, and returns an AAC/M4A file. The HTTP process starts with no Qwen model in memory. `Qwen3TTSModel` and `torch` are imported only when a job reaches the synthesis stage; the model is deleted and the CUDA cache is cleared before M4A encoding.
|
||||||
|
|
||||||
|
## Deployed layout
|
||||||
|
|
||||||
|
- CMP host: `192.168.0.112`, service root `/home/sevenhill/apps/aletheia-audiobook`
|
||||||
|
- API: `http://0.0.0.0:8765`
|
||||||
|
- jobs and SQLite state: `/home/sevenhill/apps/aletheia-audiobook/audiobook-jobs`
|
||||||
|
- API token: `/home/sevenhill/apps/aletheia-audiobook/service/service-token.txt`
|
||||||
|
- public route: `https://argus.kusoft.xyz/aletheia-tts/`
|
||||||
|
- voice: Qwen3-TTS 0.6B CustomVoice, `Ryan`, Russian
|
||||||
|
|
||||||
|
The token is intentionally not stored in this repository. The Android build reads it from `%USERPROFILE%\.aletheia\audiobook-api-token.txt` and embeds it in `BuildConfig` for this private installation.
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
The CMP receiver uses `/home/sevenhill/apps/qwen3-tts-venv`. It starts as a lightweight FastAPI process and loads the 0.6B Qwen model only after a queued audiobook reaches the synthesis stage. The model is deleted and CUDA cache cleared before M4A encoding.
|
||||||
|
|
||||||
|
The `systemd --user` service starts only the receiver process. It does not preload Qwen or reserve GPU memory; the model is loaded by an audiobook request. After a job finishes, systemd replaces the receiver process so Qwen and Triton CUDA contexts are fully released before the receiver waits for the next request.
|
||||||
|
|
||||||
|
When an audiobook enters synthesis, the receiver temporarily stops and runtime-masks Ollama, then loads one tested 0.6B Qwen worker on each of GPU 0, 1, and 2. Up to three independent chunks are generated in parallel. After the job, the receiver exits so systemd can release all CUDA contexts; its fresh idle process restores Ollama. Existing WAV chunks are never regenerated during resume.
|
||||||
|
|
||||||
|
Per-batch timings are appended to `audiobook-jobs\performance.jsonl`. The log separates the autoregressive talker, speech-tokenizer decoder, and wrapper time so runtime optimizations can be benchmarked without changing the generated audio path.
|
||||||
|
|
||||||
|
The CMP firewall must permit TCP 8765 only from the Raspberry Pi address. Caddy uses a path handler that removes the public prefix before proxying:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
argus.kusoft.xyz {
|
||||||
|
encode zstd gzip
|
||||||
|
handle_path /aletheia-tts/* {
|
||||||
|
reverse_proxy 192.168.0.112:8765
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy 127.0.0.1:5105
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate with `caddy validate` before reloading Caddy. `/health` is public and reports `modelLoaded`; all `/v1/audiobooks` endpoints require the bearer token.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `POST /v1/audiobooks` — multipart fields `book`, `title`, `author`
|
||||||
|
- `GET /v1/audiobooks/{id}` — status, stage, processed/total characters, chapter, duration and chapter markers
|
||||||
|
- `GET /v1/audiobooks/{id}/file` — completed M4A
|
||||||
|
- `DELETE /v1/audiobooks/{id}` — cancel an active job or remove a completed job
|
||||||
|
|
||||||
|
Interrupted receiver processes requeue unfinished jobs on the next start. Existing WAV chunks are reused, so synthesis resumes at the first missing chunk.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Aletheia CMP audiobook receiver
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/home/sevenhill/apps/aletheia-audiobook/service
|
||||||
|
Environment=AUDIOBOOK_SERVICE_ROOT=/home/sevenhill/apps/aletheia-audiobook
|
||||||
|
Environment=HF_HOME=/home/sevenhill/apps/qwen3-audiobook-test/hf
|
||||||
|
Environment=QWEN_TTS_MODEL_ID=Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
|
||||||
|
Environment=QWEN_TTS_SPEAKER=Ryan
|
||||||
|
Environment=QWEN_TTS_GPU_IDS=0,1,2
|
||||||
|
Environment=CPATH=/home/sevenhill/apps/qwen3-audiobook-test/sysdeps/extracted/usr/include/python3.12:/home/sevenhill/apps/qwen3-audiobook-test/sysdeps/extracted/usr/include
|
||||||
|
ExecStart=/home/sevenhill/apps/qwen3-tts-venv/bin/python -m uvicorn audiobook_service:app --host 0.0.0.0 --port 8765 --workers 1
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gc
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import TYPE_CHECKING, Annotated, Any, Iterator
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
import imageio_ffmpeg
|
||||||
|
import soundfile as sf
|
||||||
|
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from num2words import num2words
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(os.environ.get("AUDIOBOOK_SERVICE_ROOT", "/home/sevenhill/apps/aletheia-audiobook"))
|
||||||
|
SERVICE_ROOT = ROOT / "service"
|
||||||
|
DATA_ROOT = ROOT / "audiobook-jobs"
|
||||||
|
TOKEN_FILE = SERVICE_ROOT / "service-token.txt"
|
||||||
|
DATABASE = DATA_ROOT / "jobs.db3"
|
||||||
|
PERFORMANCE_LOG = DATA_ROOT / "performance.jsonl"
|
||||||
|
MODEL_ID = os.environ.get("QWEN_TTS_MODEL_ID", "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice")
|
||||||
|
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
|
||||||
|
GPU_IDS = tuple(
|
||||||
|
int(value.strip())
|
||||||
|
for value in os.environ.get("QWEN_TTS_GPU_IDS", "0,1,2").split(",")
|
||||||
|
if value.strip()
|
||||||
|
)
|
||||||
|
SPEAKER = os.environ.get("QWEN_TTS_SPEAKER", "Ryan")
|
||||||
|
VOICE_INSTRUCTION = (
|
||||||
|
"Read in a calm, clear, natural audiobook style with steady pacing and distinct diction."
|
||||||
|
)
|
||||||
|
|
||||||
|
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
DATA_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
OLLAMA_PAUSE_MARKER = SERVICE_ROOT / "ollama-paused-for-audiobook"
|
||||||
|
if not TOKEN_FILE.exists():
|
||||||
|
TOKEN_FILE.write_text(secrets.token_urlsafe(48), encoding="ascii")
|
||||||
|
API_TOKEN = TOKEN_FILE.read_text(encoding="ascii").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def pause_ollama_for_audiobook() -> None:
|
||||||
|
"""Release every CMP GPU before loading the three Qwen workers."""
|
||||||
|
subprocess.run(["systemctl", "--user", "stop", "ollama.service"], check=True)
|
||||||
|
subprocess.run(["systemctl", "--user", "mask", "--runtime", "ollama.service"], check=True)
|
||||||
|
OLLAMA_PAUSE_MARKER.write_text("1", encoding="ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def restore_ollama_after_audiobook() -> None:
|
||||||
|
if not OLLAMA_PAUSE_MARKER.exists():
|
||||||
|
return
|
||||||
|
subprocess.run(["systemctl", "--user", "unmask", "--runtime", "ollama.service"], check=True)
|
||||||
|
subprocess.run(["systemctl", "--user", "start", "ollama.service"], check=True)
|
||||||
|
OLLAMA_PAUSE_MARKER.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# The worker terminates after every job to release CUDA/Triton contexts. The
|
||||||
|
# next lightweight receiver process restores Ollama before accepting new work.
|
||||||
|
restore_ollama_after_audiobook()
|
||||||
|
|
||||||
|
app = FastAPI(title="Aletheia CMP Audiobook Service", version="1.1.0")
|
||||||
|
queue_condition = threading.Condition()
|
||||||
|
queued_jobs: list[str] = []
|
||||||
|
worker_state = {"activeJobId": None, "modelLoaded": False}
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def database() -> Iterator[sqlite3.Connection]:
|
||||||
|
connection = sqlite3.connect(DATABASE, timeout=30)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_database() -> None:
|
||||||
|
with database() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
input_path TEXT NOT NULL,
|
||||||
|
output_path TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
processed_characters INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_characters INTEGER NOT NULL DEFAULT 0,
|
||||||
|
current_chapter TEXT,
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
chapters_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
error_message TEXT,
|
||||||
|
cancel_requested INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE jobs SET status='queued', stage='queued' "
|
||||||
|
"WHERE status IN ('extracting','synthesizing','encoding')"
|
||||||
|
)
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT id FROM jobs WHERE status='queued' ORDER BY created_at"
|
||||||
|
).fetchall()
|
||||||
|
queued_jobs.extend(row["id"] for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def require_token(authorization: Annotated[str | None, Header()] = None) -> None:
|
||||||
|
expected = f"Bearer {API_TOKEN}"
|
||||||
|
if not authorization or not hmac.compare_digest(authorization, expected):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API token")
|
||||||
|
|
||||||
|
|
||||||
|
def now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(job_id: str) -> sqlite3.Row:
|
||||||
|
with database() as connection:
|
||||||
|
row = connection.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Audiobook job not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def job_payload(row: sqlite3.Row) -> dict:
|
||||||
|
total = row["total_characters"]
|
||||||
|
processed = row["processed_characters"]
|
||||||
|
progress = min(100, int(processed * 100 / total)) if total else 0
|
||||||
|
if row["status"] == "ready":
|
||||||
|
progress = 100
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"status": row["status"],
|
||||||
|
"stage": row["stage"],
|
||||||
|
"progress": progress,
|
||||||
|
"processedCharacters": processed,
|
||||||
|
"totalCharacters": total,
|
||||||
|
"currentChapter": row["current_chapter"],
|
||||||
|
"durationMs": row["duration_ms"],
|
||||||
|
"chapters": json.loads(row["chapters_json"] or "[]"),
|
||||||
|
"error": row["error_message"],
|
||||||
|
"createdAt": row["created_at"],
|
||||||
|
"updatedAt": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"workerMode": "lazy-model",
|
||||||
|
"activeJobId": worker_state["activeJobId"],
|
||||||
|
"modelLoaded": worker_state["modelLoaded"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/audiobooks", dependencies=[Depends(require_token)])
|
||||||
|
async def create_audiobook(
|
||||||
|
book: Annotated[UploadFile, File()],
|
||||||
|
title: Annotated[str, Form()],
|
||||||
|
author: Annotated[str, Form()] = "",
|
||||||
|
) -> dict:
|
||||||
|
extension = Path(book.filename or "book.epub").suffix.lower()
|
||||||
|
if extension not in {".epub", ".fb2"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Only EPUB and FB2 are supported")
|
||||||
|
job_id = uuid.uuid4().hex
|
||||||
|
job_root = DATA_ROOT / job_id
|
||||||
|
job_root.mkdir(parents=True)
|
||||||
|
input_path = job_root / f"source{extension}"
|
||||||
|
received = 0
|
||||||
|
try:
|
||||||
|
with input_path.open("wb") as output:
|
||||||
|
while chunk := await book.read(1024 * 1024):
|
||||||
|
received += len(chunk)
|
||||||
|
if received > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Book file is too large")
|
||||||
|
output.write(chunk)
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(job_root, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
timestamp = now_ms()
|
||||||
|
with database() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO jobs (
|
||||||
|
id,title,author,source_name,input_path,output_path,status,stage,created_at,updated_at
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
job_id,
|
||||||
|
title.strip() or Path(book.filename or "Книга").stem,
|
||||||
|
author.strip(),
|
||||||
|
book.filename or input_path.name,
|
||||||
|
str(input_path),
|
||||||
|
str(job_root / "audiobook.m4a"),
|
||||||
|
"queued",
|
||||||
|
"queued",
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with queue_condition:
|
||||||
|
queued_jobs.append(job_id)
|
||||||
|
queue_condition.notify()
|
||||||
|
return job_payload(get_job(job_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/audiobooks/{job_id}", dependencies=[Depends(require_token)])
|
||||||
|
def audiobook_status(job_id: str) -> dict:
|
||||||
|
return job_payload(get_job(job_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/audiobooks/{job_id}/file", dependencies=[Depends(require_token)])
|
||||||
|
def audiobook_file(job_id: str) -> FileResponse:
|
||||||
|
row = get_job(job_id)
|
||||||
|
output = Path(row["output_path"])
|
||||||
|
if row["status"] != "ready" or not output.is_file():
|
||||||
|
raise HTTPException(status_code=409, detail="Audiobook is not ready")
|
||||||
|
safe_title = re.sub(r"[^0-9A-Za-zА-Яа-яЁё._ -]+", "_", row["title"]).strip() or "audiobook"
|
||||||
|
return FileResponse(output, media_type="audio/mp4", filename=f"{safe_title}.m4a")
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/v1/audiobooks/{job_id}", dependencies=[Depends(require_token)])
|
||||||
|
def cancel_or_delete(job_id: str) -> dict:
|
||||||
|
row = get_job(job_id)
|
||||||
|
if row["status"] == "queued":
|
||||||
|
with queue_condition:
|
||||||
|
queued_jobs[:] = [queued_id for queued_id in queued_jobs if queued_id != job_id]
|
||||||
|
delete_job(job_id, Path(row["input_path"]).parent)
|
||||||
|
return {"status": "deleted"}
|
||||||
|
if row["status"] in {"extracting", "synthesizing", "encoding"}:
|
||||||
|
with database() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE jobs SET cancel_requested=1, stage='cancelling', updated_at=? WHERE id=?",
|
||||||
|
(now_ms(), job_id),
|
||||||
|
)
|
||||||
|
return {"status": "cancelling"}
|
||||||
|
delete_job(job_id, Path(row["input_path"]).parent)
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_job(job_id: str, job_root: Path) -> None:
|
||||||
|
shutil.rmtree(job_root, ignore_errors=True)
|
||||||
|
with database() as connection:
|
||||||
|
connection.execute("DELETE FROM jobs WHERE id=?", (job_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def finish_cancellation(job_id: str, job_root: Path) -> None:
|
||||||
|
update_job(job_id, status="cancelled", stage="cancelled")
|
||||||
|
delete_job(job_id, job_root)
|
||||||
|
|
||||||
|
|
||||||
|
def local_name(tag: str) -> str:
|
||||||
|
return tag.rsplit("}", 1)[-1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def clean_text(value: str) -> str:
|
||||||
|
value = html.unescape(re.sub(r"<[^>]+>", " ", value))
|
||||||
|
value = value.replace("\u00a0", " ")
|
||||||
|
return re.sub(r"\s+", " ", value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_epub(path: Path) -> list[tuple[str, str]]:
|
||||||
|
sections: list[tuple[str, str]] = []
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
container = ElementTree.fromstring(archive.read("META-INF/container.xml"))
|
||||||
|
rootfile = next(node for node in container.iter() if local_name(node.tag) == "rootfile")
|
||||||
|
opf_path = rootfile.attrib["full-path"]
|
||||||
|
opf = ElementTree.fromstring(archive.read(opf_path))
|
||||||
|
manifest = {
|
||||||
|
node.attrib["id"]: node.attrib["href"]
|
||||||
|
for node in opf.iter()
|
||||||
|
if local_name(node.tag) == "item" and "id" in node.attrib and "href" in node.attrib
|
||||||
|
}
|
||||||
|
spine = [
|
||||||
|
node.attrib["idref"]
|
||||||
|
for node in opf.iter()
|
||||||
|
if local_name(node.tag) == "itemref" and "idref" in node.attrib
|
||||||
|
]
|
||||||
|
base = PurePosixPath(opf_path).parent
|
||||||
|
for index, item_id in enumerate(spine, start=1):
|
||||||
|
href = manifest.get(item_id)
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
entry = str(base / href.split("#", 1)[0])
|
||||||
|
raw = archive.read(entry).decode("utf-8", errors="replace")
|
||||||
|
heading_match = re.search(r"<h[1-3]\b[^>]*>(.*?)</h[1-3]>", raw, re.I | re.S)
|
||||||
|
title = clean_text(heading_match.group(1)) if heading_match else f"Раздел {index}"
|
||||||
|
paragraphs = [clean_text(match) for match in re.findall(r"<p\b[^>]*>(.*?)</p>", raw, re.I | re.S)]
|
||||||
|
text = "\n".join(paragraph for paragraph in paragraphs if paragraph)
|
||||||
|
if text:
|
||||||
|
sections.append((title, text))
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def element_text_without_nested_sections(element: ElementTree.Element) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
if element.text:
|
||||||
|
parts.append(element.text)
|
||||||
|
for child in element:
|
||||||
|
if local_name(child.tag) != "section":
|
||||||
|
parts.append(" ".join(child.itertext()))
|
||||||
|
if child.tail:
|
||||||
|
parts.append(child.tail)
|
||||||
|
return clean_text(" ".join(parts))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_fb2(path: Path) -> list[tuple[str, str]]:
|
||||||
|
root = ElementTree.parse(path).getroot()
|
||||||
|
sections: list[tuple[str, str]] = []
|
||||||
|
for section in root.iter():
|
||||||
|
if local_name(section.tag) != "section":
|
||||||
|
continue
|
||||||
|
title_element = next((child for child in section if local_name(child.tag) == "title"), None)
|
||||||
|
title = clean_text(" ".join(title_element.itertext())) if title_element is not None else ""
|
||||||
|
text = element_text_without_nested_sections(section)
|
||||||
|
if text:
|
||||||
|
sections.append((title or f"Раздел {len(sections) + 1}", text))
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_numbers(text: str) -> str:
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
value = match.group(0)
|
||||||
|
try:
|
||||||
|
return num2words(int(value), lang="ru")
|
||||||
|
except (ValueError, OverflowError):
|
||||||
|
return value
|
||||||
|
|
||||||
|
return re.sub(r"(?<![\w+])\d{1,12}(?!\w)", replace, text)
|
||||||
|
|
||||||
|
|
||||||
|
def split_chunks(sections: list[tuple[str, str]], limit: int = 520) -> list[dict]:
|
||||||
|
chunks: list[dict] = []
|
||||||
|
for chapter, text in sections:
|
||||||
|
sentences = re.split(r"(?<=[.!?…])\s+|\n+", text)
|
||||||
|
current = ""
|
||||||
|
for sentence in sentences:
|
||||||
|
sentence = normalize_numbers(sentence.strip())
|
||||||
|
if not sentence or not re.search(r"[А-Яа-яЁё]", sentence):
|
||||||
|
continue
|
||||||
|
if len(sentence) > limit:
|
||||||
|
pieces = re.split(r"(?<=[,;:])\s+", sentence)
|
||||||
|
else:
|
||||||
|
pieces = [sentence]
|
||||||
|
for piece in pieces:
|
||||||
|
if current and len(current) + 1 + len(piece) > limit:
|
||||||
|
chunks.append({"chapter": chapter, "text": current})
|
||||||
|
current = piece
|
||||||
|
else:
|
||||||
|
current = f"{current} {piece}".strip()
|
||||||
|
if current:
|
||||||
|
chunks.append({"chapter": chapter, "text": current})
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def update_job(job_id: str, **values: object) -> None:
|
||||||
|
values["updated_at"] = now_ms()
|
||||||
|
assignments = ",".join(f"{key}=?" for key in values)
|
||||||
|
with database() as connection:
|
||||||
|
connection.execute(
|
||||||
|
f"UPDATE jobs SET {assignments} WHERE id=?",
|
||||||
|
(*values.values(), job_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cancellation_requested(job_id: str) -> bool:
|
||||||
|
with database() as connection:
|
||||||
|
row = connection.execute("SELECT cancel_requested FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||||
|
return row is None or bool(row["cancel_requested"])
|
||||||
|
|
||||||
|
|
||||||
|
def load_model(device_id: int) -> "Qwen3TTSModel":
|
||||||
|
import torch
|
||||||
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise RuntimeError("CUDA is unavailable")
|
||||||
|
torch.set_float32_matmul_precision("high")
|
||||||
|
model = Qwen3TTSModel.from_pretrained(
|
||||||
|
MODEL_ID,
|
||||||
|
device_map=f"cuda:{device_id}",
|
||||||
|
dtype=torch.bfloat16,
|
||||||
|
attn_implementation="sdpa",
|
||||||
|
)
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def load_models() -> list["Qwen3TTSModel"]:
|
||||||
|
if not GPU_IDS:
|
||||||
|
raise RuntimeError("No Qwen GPU IDs are configured")
|
||||||
|
models = [load_model(device_id) for device_id in GPU_IDS]
|
||||||
|
worker_state["modelLoaded"] = True
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
|
def generate_custom_voice_with_metrics(
|
||||||
|
model: Any,
|
||||||
|
batch_chunks: list[dict],
|
||||||
|
device_id: int,
|
||||||
|
) -> tuple[list[Any], int]:
|
||||||
|
"""Measure Qwen talker and codec stages without changing generated audio."""
|
||||||
|
import torch
|
||||||
|
|
||||||
|
timings: dict[str, float] = {}
|
||||||
|
original_generate = model.model.generate
|
||||||
|
speech_tokenizer = model.model.speech_tokenizer
|
||||||
|
original_decode = speech_tokenizer.decode
|
||||||
|
|
||||||
|
def synchronize() -> None:
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.cuda.synchronize(device_id)
|
||||||
|
|
||||||
|
def timed_generate(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
synchronize()
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
return original_generate(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
synchronize()
|
||||||
|
timings["talkerGenerateSeconds"] = time.perf_counter() - started
|
||||||
|
|
||||||
|
def timed_decode(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
synchronize()
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
return original_decode(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
synchronize()
|
||||||
|
timings["codecDecodeSeconds"] = time.perf_counter() - started
|
||||||
|
|
||||||
|
model.model.generate = timed_generate
|
||||||
|
speech_tokenizer.decode = timed_decode
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
wavs, sample_rate = model.generate_custom_voice(
|
||||||
|
text=[chunk["text"] for chunk in batch_chunks],
|
||||||
|
language=["Russian"] * len(batch_chunks),
|
||||||
|
speaker=[SPEAKER] * len(batch_chunks),
|
||||||
|
instruct=[VOICE_INSTRUCTION] * len(batch_chunks),
|
||||||
|
)
|
||||||
|
synchronize()
|
||||||
|
finally:
|
||||||
|
model.model.generate = original_generate
|
||||||
|
speech_tokenizer.decode = original_decode
|
||||||
|
|
||||||
|
total_seconds = time.perf_counter() - started
|
||||||
|
talker_seconds = timings.get("talkerGenerateSeconds", 0.0)
|
||||||
|
codec_seconds = timings.get("codecDecodeSeconds", 0.0)
|
||||||
|
record = {
|
||||||
|
"timestampMs": now_ms(),
|
||||||
|
"batchSize": len(batch_chunks),
|
||||||
|
"characters": sum(len(chunk["text"]) for chunk in batch_chunks),
|
||||||
|
"totalSeconds": round(total_seconds, 4),
|
||||||
|
"talkerGenerateSeconds": round(talker_seconds, 4),
|
||||||
|
"codecDecodeSeconds": round(codec_seconds, 4),
|
||||||
|
"wrapperSeconds": round(max(0.0, total_seconds - talker_seconds - codec_seconds), 4),
|
||||||
|
"cudaPeakAllocatedMiB": round(
|
||||||
|
torch.cuda.max_memory_allocated(device_id) / (1024 * 1024), 1
|
||||||
|
),
|
||||||
|
}
|
||||||
|
with PERFORMANCE_LOG.open("a", encoding="utf-8") as output:
|
||||||
|
output.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||||
|
return wavs, sample_rate
|
||||||
|
|
||||||
|
|
||||||
|
def unload_models(models: list[Any]) -> None:
|
||||||
|
if models:
|
||||||
|
models.clear()
|
||||||
|
import torch
|
||||||
|
|
||||||
|
gc.collect()
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
for device_id in GPU_IDS:
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
else:
|
||||||
|
gc.collect()
|
||||||
|
worker_state["modelLoaded"] = False
|
||||||
|
|
||||||
|
|
||||||
|
def encode_m4a(chunk_files: list[Path], output: Path, job_root: Path) -> None:
|
||||||
|
concat = job_root / "concat.txt"
|
||||||
|
concat.write_text(
|
||||||
|
"\n".join(f"file '{path.as_posix()}'" for path in chunk_files),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
command = [
|
||||||
|
imageio_ffmpeg.get_ffmpeg_exe(),
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"concat",
|
||||||
|
"-safe",
|
||||||
|
"0",
|
||||||
|
"-i",
|
||||||
|
str(concat),
|
||||||
|
"-vn",
|
||||||
|
"-c:a",
|
||||||
|
"aac",
|
||||||
|
"-b:a",
|
||||||
|
"96k",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
str(output),
|
||||||
|
]
|
||||||
|
completed = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise RuntimeError(completed.stderr[-2000:] or "FFmpeg failed")
|
||||||
|
|
||||||
|
|
||||||
|
def run_job(job_id: str) -> None:
|
||||||
|
row = get_job(job_id)
|
||||||
|
job_root = Path(row["input_path"]).parent
|
||||||
|
chunks_root = job_root / "chunks"
|
||||||
|
chunks_root.mkdir(exist_ok=True)
|
||||||
|
models: list[Any] = []
|
||||||
|
try:
|
||||||
|
if cancellation_requested(job_id):
|
||||||
|
finish_cancellation(job_id, job_root)
|
||||||
|
return
|
||||||
|
update_job(job_id, status="extracting", stage="extracting", error_message=None)
|
||||||
|
input_path = Path(row["input_path"])
|
||||||
|
sections = extract_epub(input_path) if input_path.suffix.lower() == ".epub" else extract_fb2(input_path)
|
||||||
|
chunks = split_chunks(sections)
|
||||||
|
if not chunks:
|
||||||
|
raise RuntimeError("В книге не найден русский текст для озвучивания")
|
||||||
|
total = sum(len(chunk["text"]) for chunk in chunks)
|
||||||
|
manifest_path = job_root / "chunks.json"
|
||||||
|
manifest_path.write_text(json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
update_job(job_id, status="synthesizing", stage="loading_model", total_characters=total)
|
||||||
|
if cancellation_requested(job_id):
|
||||||
|
finish_cancellation(job_id, job_root)
|
||||||
|
return
|
||||||
|
pause_ollama_for_audiobook()
|
||||||
|
models = load_models()
|
||||||
|
import torch
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
duration_ms = 0
|
||||||
|
chapter_markers: list[dict] = []
|
||||||
|
last_chapter: str | None = None
|
||||||
|
chunk_files: list[Path] = []
|
||||||
|
index = 0
|
||||||
|
while index < len(chunks):
|
||||||
|
if cancellation_requested(job_id):
|
||||||
|
finish_cancellation(job_id, job_root)
|
||||||
|
return
|
||||||
|
output = chunks_root / f"{index:06d}.wav"
|
||||||
|
completed_indices: list[int]
|
||||||
|
if output.is_file():
|
||||||
|
completed_indices = [index]
|
||||||
|
else:
|
||||||
|
batch_indices: list[int] = []
|
||||||
|
cursor = index
|
||||||
|
while cursor < len(chunks) and len(batch_indices) < len(models):
|
||||||
|
candidate = chunks_root / f"{cursor:06d}.wav"
|
||||||
|
if candidate.is_file():
|
||||||
|
break
|
||||||
|
batch_indices.append(cursor)
|
||||||
|
cursor += 1
|
||||||
|
with ThreadPoolExecutor(max_workers=len(batch_indices)) as executor:
|
||||||
|
futures = [
|
||||||
|
executor.submit(
|
||||||
|
generate_custom_voice_with_metrics,
|
||||||
|
model,
|
||||||
|
[chunks[batch_index]],
|
||||||
|
device_id,
|
||||||
|
)
|
||||||
|
for batch_index, model, device_id in zip(
|
||||||
|
batch_indices,
|
||||||
|
models[: len(batch_indices)],
|
||||||
|
GPU_IDS[: len(batch_indices)],
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
rendered = [future.result() for future in futures]
|
||||||
|
if cancellation_requested(job_id):
|
||||||
|
finish_cancellation(job_id, job_root)
|
||||||
|
return
|
||||||
|
for batch_index, (wavs, sample_rate) in zip(batch_indices, rendered, strict=True):
|
||||||
|
sf.write(
|
||||||
|
chunks_root / f"{batch_index:06d}.wav",
|
||||||
|
wavs[0],
|
||||||
|
sample_rate,
|
||||||
|
subtype="PCM_16",
|
||||||
|
)
|
||||||
|
completed_indices = batch_indices
|
||||||
|
|
||||||
|
for completed_index in completed_indices:
|
||||||
|
completed_chunk = chunks[completed_index]
|
||||||
|
completed_output = chunks_root / f"{completed_index:06d}.wav"
|
||||||
|
if completed_chunk["chapter"] != last_chapter:
|
||||||
|
chapter_markers.append({"title": completed_chunk["chapter"], "startMs": duration_ms})
|
||||||
|
last_chapter = completed_chunk["chapter"]
|
||||||
|
info = sf.info(completed_output)
|
||||||
|
duration_ms += round(info.frames * 1000 / info.samplerate)
|
||||||
|
chunk_files.append(completed_output)
|
||||||
|
processed += len(completed_chunk["text"])
|
||||||
|
update_job(
|
||||||
|
job_id,
|
||||||
|
status="synthesizing",
|
||||||
|
stage="synthesizing",
|
||||||
|
processed_characters=processed,
|
||||||
|
current_chapter=completed_chunk["chapter"],
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
chapters_json=json.dumps(chapter_markers, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
index = completed_indices[-1] + 1
|
||||||
|
|
||||||
|
unload_models(models)
|
||||||
|
models = []
|
||||||
|
if cancellation_requested(job_id):
|
||||||
|
finish_cancellation(job_id, job_root)
|
||||||
|
return
|
||||||
|
update_job(job_id, status="encoding", stage="encoding", current_chapter=None)
|
||||||
|
output = Path(row["output_path"])
|
||||||
|
encode_m4a(chunk_files, output, job_root)
|
||||||
|
if cancellation_requested(job_id):
|
||||||
|
finish_cancellation(job_id, job_root)
|
||||||
|
return
|
||||||
|
if not output.is_file() or output.stat().st_size == 0:
|
||||||
|
raise RuntimeError("Итоговый M4A-файл не создан")
|
||||||
|
update_job(
|
||||||
|
job_id,
|
||||||
|
status="ready",
|
||||||
|
stage="ready",
|
||||||
|
processed_characters=total,
|
||||||
|
current_chapter=None,
|
||||||
|
chapters_json=json.dumps(chapter_markers, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
update_job(
|
||||||
|
job_id,
|
||||||
|
status="failed",
|
||||||
|
stage="failed",
|
||||||
|
error_message=f"{type(error).__name__}: {error}",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
unload_models(models)
|
||||||
|
|
||||||
|
|
||||||
|
def worker_loop() -> None:
|
||||||
|
while True:
|
||||||
|
with queue_condition:
|
||||||
|
while not queued_jobs:
|
||||||
|
queue_condition.wait()
|
||||||
|
job_id = queued_jobs.pop(0)
|
||||||
|
worker_state["activeJobId"] = job_id
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
run_job(job_id)
|
||||||
|
except HTTPException as error:
|
||||||
|
if error.status_code != 404:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
worker_state["activeJobId"] = None
|
||||||
|
# Qwen/Triton can retain a CUDA context after Python references are
|
||||||
|
# released. Let systemd start a clean idle receiver so CMP VRAM is
|
||||||
|
# fully available between audiobook requests. Unfinished jobs are
|
||||||
|
# restored from SQLite by initialize_database() after restart.
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
initialize_database()
|
||||||
|
threading.Thread(target=worker_loop, name="audiobook-worker", daemon=True).start()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$taskName = 'Aletheia Audiobook Receiver'
|
||||||
|
$serviceScript = 'C:\Users\seven\Qwen3-TTS\service\start_service.ps1'
|
||||||
|
|
||||||
|
$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $existing) {
|
||||||
|
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = New-ScheduledTaskAction `
|
||||||
|
-Execute 'powershell.exe' `
|
||||||
|
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$serviceScript`""
|
||||||
|
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||||
|
$principal = New-ScheduledTaskPrincipal `
|
||||||
|
-UserId 'SYSTEM' `
|
||||||
|
-LogonType ServiceAccount `
|
||||||
|
-RunLevel Highest
|
||||||
|
$settings = New-ScheduledTaskSettingsSet `
|
||||||
|
-AllowStartIfOnBatteries `
|
||||||
|
-DontStopIfGoingOnBatteries `
|
||||||
|
-StartWhenAvailable `
|
||||||
|
-RestartCount 5 `
|
||||||
|
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||||
|
-ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||||
|
|
||||||
|
Register-ScheduledTask `
|
||||||
|
-TaskName $taskName `
|
||||||
|
-Description 'Lightweight Aletheia API receiver. The Qwen model is loaded only while processing a job.' `
|
||||||
|
-Action $action `
|
||||||
|
-Trigger $trigger `
|
||||||
|
-Principal $principal `
|
||||||
|
-Settings $settings | Out-Null
|
||||||
|
|
||||||
|
Start-ScheduledTask -TaskName $taskName
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
imageio-ffmpeg==0.6.0
|
||||||
|
num2words==0.5.14
|
||||||
|
python-multipart==0.0.32
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$root = 'C:\Users\seven\Qwen3-TTS'
|
||||||
|
$logs = Join-Path $root 'logs'
|
||||||
|
New-Item -ItemType Directory -Force -Path $logs | Out-Null
|
||||||
|
Start-Transcript -Path (Join-Path $logs 'audiobook-receiver.log') -Append | Out-Null
|
||||||
|
$env:QWEN_TTS_ROOT = $root
|
||||||
|
$env:HF_HUB_OFFLINE = '1'
|
||||||
|
$env:TRANSFORMERS_OFFLINE = '1'
|
||||||
|
Set-Location (Join-Path $root 'service')
|
||||||
|
|
||||||
|
try {
|
||||||
|
& (Join-Path $root '.venv\Scripts\python.exe') -m uvicorn audiobook_service:app `
|
||||||
|
--host 0.0.0.0 `
|
||||||
|
--port 8765 `
|
||||||
|
--workers 1
|
||||||
|
} finally {
|
||||||
|
Stop-Transcript | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user