feat: add remote audiobook generation

This commit is contained in:
Курнат Андрей
2026-08-10 05:24:47 +03:00
parent b115f76e76
commit 173bd672aa
42 changed files with 2300 additions and 125903 deletions
+23 -7
View File
@@ -1,7 +1,15 @@
plugins {
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
val audiobookApiTokenFile = file("${System.getProperty("user.home")}/.aletheia/audiobook-api-token.txt")
val audiobookApiToken = if (audiobookApiTokenFile.isFile) {
audiobookApiTokenFile.readText().trim()
} else {
""
}
fun quotedBuildConfig(value: String): String =
"\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
android {
namespace = "com.aletheia.app"
@@ -11,8 +19,15 @@ android {
applicationId = "com.aletheia.app"
minSdk = 24
targetSdk = 36
versionCode = 45
versionName = "2.34"
versionCode = 48
versionName = "2.37"
buildConfigField(
"String",
"AUDIOBOOK_API_BASE_URL",
quotedBuildConfig("https://argus.kusoft.xyz/aletheia-tts/")
)
buildConfigField("String", "AUDIOBOOK_API_TOKEN", quotedBuildConfig(audiobookApiToken))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -49,6 +64,7 @@ android {
buildFeatures {
viewBinding = true
buildConfig = true
}
packaging {
@@ -57,9 +73,6 @@ android {
}
}
androidResources {
noCompress += "onnx"
}
}
dependencies {
@@ -74,7 +87,10 @@ dependencies {
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.webkit:webkit:1.12.1")
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.27.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test:runner:1.6.2")
}
-4
View File
@@ -5,7 +5,3 @@
# Keep the manifest-declared Argus package installer callback constructable by Android.
-keep class xyz.kusoft.argusupdater.ArgusPackageInstallerStatusReceiver { public <init>(); }
# ONNX Runtime's native JNI layer resolves these Java classes and members by
# their original names when converting native OrtValues to Java OnnxValues.
-keep class ai.onnxruntime.** { *; }
+16
View File
@@ -3,6 +3,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application
android:name=".AletheiaApplication"
@@ -25,12 +28,25 @@
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".audiobook.AudioBookGenerationService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="User-requested audiobook upload, remote progress monitoring, and M4A download" />
</service>
<activity
android:name=".ui.reader.ReaderActivity"
android:configChanges="orientation|screenSize|smallestScreenSize"
android:exported="false"
android:theme="@style/Theme.Aletheia"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.audiobook.AudioBookPlayerActivity"
android:exported="false"
android:theme="@style/Theme.Aletheia" />
<activity
android:name=".ui.qbooks.BookDetailActivity"
android:exported="false"
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Binary file not shown.
Binary file not shown.
1 version https://git-lfs.github.com/spec/v1
2 oid sha256:db5eaadd86c5fde2b68b371bafbf07d2102de3bef861fed3468456a839190378
3 size 96070019
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ package com.aletheia.app
import android.app.Application
import com.aletheia.app.data.AppDatabaseHelper
import com.aletheia.app.data.AudioBookRepository
import com.aletheia.app.data.BookParserService
import com.aletheia.app.data.BookRepository
import com.aletheia.app.data.CatalogCache
@@ -35,6 +36,9 @@ class AletheiaApplication : Application() {
lateinit var bookRepository: BookRepository
private set
lateinit var audioBookRepository: AudioBookRepository
private set
lateinit var qBooksService: QBooksService
private set
@@ -53,6 +57,7 @@ class AletheiaApplication : Application() {
settingsRepository = SettingsRepository(this, databaseHelper)
bookParserService = BookParserService(this)
bookRepository = BookRepository(databaseHelper, bookParserService, settingsRepository)
audioBookRepository = AudioBookRepository(this, databaseHelper)
catalogCache = CatalogCache(this)
qBooksService = QBooksService(settingsRepository, catalogCache)
appUpdateManager = ArgusUpdateManager(this)
@@ -0,0 +1,297 @@
package com.aletheia.app.audiobook
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.aletheia.app.AletheiaApplication
import com.aletheia.app.R
import com.aletheia.app.model.AudioBook
import java.io.File
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class AudioBookGenerationService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val jobs = ConcurrentHashMap<Long, Job>()
private val userCancellations = ConcurrentHashMap.newKeySet<Long>()
private val app by lazy { application as AletheiaApplication }
private val client by lazy { RemoteAudioBookClient() }
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val audioBookId = intent?.getLongExtra(EXTRA_AUDIOBOOK_ID, 0L) ?: 0L
when (intent?.action) {
ACTION_CANCEL -> cancelGeneration(audioBookId)
ACTION_START -> if (audioBookId > 0 && jobs[audioBookId]?.isActive != true) {
startInForeground(audioBookId, 0, "Подключение к серверу…")
jobs[audioBookId] = scope.launch {
try {
generate(audioBookId)
} finally {
jobs.remove(audioBookId)
if (jobs.isEmpty()) stopSelf()
}
}
}
}
return START_REDELIVER_INTENT
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private suspend fun generate(audioBookId: Long) {
var record = app.audioBookRepository.getById(audioBookId) ?: return
val book = app.bookRepository.getBookById(record.sourceBookId)
?: return fail(record, "Исходная книга не найдена")
val source = File(book.filePath)
val partial = app.audioBookRepository.partialOutputFile(audioBookId)
val output = app.audioBookRepository.outputFile(audioBookId)
try {
var remoteJobId = record.remoteJobId
if (remoteJobId.isNullOrBlank()) {
record = saveProgress(
record.copy(
status = AudioBook.Status.GENERATING,
processedCharacters = 0,
totalCharacters = 0,
currentChapter = "Отправка книги на CMP-сервер…",
errorMessage = null
)
)
val created = client.create(source, book.title, book.author)
remoteJobId = created.id
record = saveProgress(record.copy(remoteJobId = remoteJobId))
}
while (true) {
val remote = try {
client.status(remoteJobId)
} catch (error: IOException) {
record = saveProgress(
record.copy(currentChapter = "Нет связи с сервером, повтор через 15 секунд…")
)
delay(RETRY_DELAY_MS)
continue
}
record = saveProgress(
record.copy(
status = AudioBook.Status.GENERATING,
processedCharacters = remote.processedCharacters,
totalCharacters = remote.totalCharacters,
durationMs = remote.durationMs,
chaptersJson = remote.chaptersJson,
currentChapter = remote.currentChapter ?: stageLabel(remote.stage),
errorMessage = null
)
)
startInForeground(audioBookId, record.progressPercent, record.currentChapter.orEmpty())
when (remote.status) {
"ready" -> break
"failed" -> throw IOException(remote.error ?: "CMP-сервер не смог создать аудиокнигу")
"cancelled" -> {
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
return
}
}
delay(POLL_DELAY_MS)
}
partial.delete()
record = saveProgress(record.copy(currentChapter = "Скачивание готового M4A…"))
client.download(remoteJobId, partial)
output.delete()
check(partial.renameTo(output)) { "Не удалось сохранить готовый M4A-файл" }
record = saveProgress(
record.copy(
filePath = output.absolutePath,
remoteJobId = null,
status = AudioBook.Status.READY,
processedCharacters = record.totalCharacters,
currentChapter = null
)
)
runCatching { client.cancelOrDelete(remoteJobId) }
.onFailure { Log.w(TAG, "Remote job cleanup failed for id=$remoteJobId", it) }
notifyComplete(record)
} catch (cancelled: CancellationException) {
if (userCancellations.remove(audioBookId)) {
record.remoteJobId?.let { runCatching { client.cancelOrDelete(it) } }
partial.delete()
if (app.audioBookRepository.getById(audioBookId) != null) {
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
}
}
throw cancelled
} catch (error: Throwable) {
partial.delete()
Log.e(TAG, "Audiobook generation failed for id=$audioBookId", error)
fail(record, error.diagnosticMessage())
}
}
private fun cancelGeneration(audioBookId: Long) {
if (audioBookId <= 0) return
userCancellations.add(audioBookId)
val active = jobs[audioBookId]
if (active != null) {
active.cancel()
return
}
scope.launch {
val record = app.audioBookRepository.getById(audioBookId) ?: return@launch
record.remoteJobId?.let { runCatching { client.cancelOrDelete(it) } }
app.audioBookRepository.partialOutputFile(audioBookId).delete()
if (app.audioBookRepository.getById(audioBookId) != null) {
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
}
userCancellations.remove(audioBookId)
}
}
private fun saveProgress(audioBook: AudioBook): AudioBook =
app.audioBookRepository.save(audioBook).also(::broadcastProgress)
private fun stageLabel(stage: String): String = when (stage) {
"queued" -> "В очереди на CMP-сервере…"
"extracting" -> "Подготовка текста книги…"
"loading_model" -> "Загрузка мужского голоса Qwen…"
"synthesizing" -> "Создание аудиокниги…"
"encoding" -> "Сборка итогового M4A…"
"ready" -> "Аудиокнига готова"
else -> "Обработка на CMP-сервере…"
}
private fun Throwable.diagnosticMessage(): String =
generateSequence(this) { it.cause }.take(4).joinToString("") { cause ->
cause.message?.takeIf(String::isNotBlank) ?: cause.javaClass.simpleName
}
private fun fail(record: AudioBook, message: String) {
val failed = saveProgress(
record.copy(status = AudioBook.Status.FAILED, currentChapter = null, errorMessage = message)
)
notificationManager.notify(
notificationId(failed.id),
notification(failed.id, failed.progressPercent, "Ошибка: $message", ongoing = false)
)
}
private fun broadcastProgress(audioBook: AudioBook) {
sendBroadcast(
Intent(ACTION_PROGRESS)
.setPackage(packageName)
.putExtra(EXTRA_AUDIOBOOK_ID, audioBook.id)
)
}
private fun startInForeground(id: Long, progress: Int, detail: String) {
ServiceCompat.startForeground(
this,
notificationId(id),
notification(id, progress, detail, ongoing = true),
if (Build.VERSION.SDK_INT >= 34) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0
)
}
private fun notifyComplete(audioBook: AudioBook) {
notificationManager.notify(
notificationId(audioBook.id),
notification(audioBook.id, 100, "Аудиокнига готова", ongoing = false)
)
}
private fun notification(id: Long, progress: Int, detail: String, ongoing: Boolean) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_reader_v2_voice)
.setContentTitle("Создание аудиокниги · Qwen, мужской голос")
.setContentText(detail)
.setOnlyAlertOnce(true)
.setOngoing(ongoing)
.setProgress(100, progress, ongoing && progress <= 0)
.apply {
if (ongoing) {
addAction(
0,
"Отменить",
PendingIntent.getService(
this@AudioBookGenerationService,
id.toInt(),
Intent(this@AudioBookGenerationService, AudioBookGenerationService::class.java)
.setAction(ACTION_CANCEL)
.putExtra(EXTRA_AUDIOBOOK_ID, id),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
}
}
.build()
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Создание аудиокниг", NotificationManager.IMPORTANCE_LOW)
)
}
}
private val notificationManager get() = getSystemService(NotificationManager::class.java)
private fun notificationId(id: Long) = NOTIFICATION_BASE + (id % 10_000).toInt()
companion object {
const val ACTION_PROGRESS = "com.aletheia.app.audiobook.PROGRESS"
private const val ACTION_START = "com.aletheia.app.audiobook.START"
private const val ACTION_CANCEL = "com.aletheia.app.audiobook.CANCEL"
const val EXTRA_AUDIOBOOK_ID = "audio_book_id"
private const val CHANNEL_ID = "audiobook_generation"
private const val NOTIFICATION_BASE = 52_000
private const val TAG = "AudioBookGeneration"
private const val POLL_DELAY_MS = 3_000L
private const val RETRY_DELAY_MS = 15_000L
fun start(context: Context, audioBookId: Long) {
ContextCompat.startForegroundService(
context,
Intent(context, AudioBookGenerationService::class.java)
.setAction(ACTION_START)
.putExtra(EXTRA_AUDIOBOOK_ID, audioBookId)
)
}
fun cancel(context: Context, audioBookId: Long) {
context.startService(
Intent(context, AudioBookGenerationService::class.java)
.setAction(ACTION_CANCEL)
.putExtra(EXTRA_AUDIOBOOK_ID, audioBookId)
)
}
}
}
@@ -0,0 +1,161 @@
package com.aletheia.app.audiobook
import com.aletheia.app.BuildConfig
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
import java.util.UUID
import org.json.JSONObject
class RemoteAudioBookClient(
private val baseUrl: String = BuildConfig.AUDIOBOOK_API_BASE_URL,
private val token: String = BuildConfig.AUDIOBOOK_API_TOKEN
) {
fun create(source: File, title: String, author: String): RemoteJob {
require(source.isFile) { "Файл исходной книги не найден" }
val boundary = "aletheia-${UUID.randomUUID()}"
val connection = open("v1/audiobooks").apply {
requestMethod = "POST"
doOutput = true
setChunkedStreamingMode(256 * 1024)
setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
}
try {
BufferedOutputStream(connection.outputStream, 256 * 1024).use { output ->
output.writeTextPart(boundary, "title", title)
output.writeTextPart(boundary, "author", author)
val safeName = source.name.replace('"', '_')
output.writeAscii("--$boundary\r\n")
output.writeAscii("Content-Disposition: form-data; name=\"book\"; filename=\"$safeName\"\r\n")
output.writeAscii("Content-Type: ${contentType(source)}\r\n\r\n")
source.inputStream().buffered(256 * 1024).use { it.copyTo(output, 256 * 1024) }
output.writeAscii("\r\n--$boundary--\r\n")
}
return parseJob(connection.readSuccessfulBody())
} finally {
connection.disconnect()
}
}
fun status(jobId: String): RemoteJob {
val connection = open("v1/audiobooks/$jobId")
return try {
parseJob(connection.readSuccessfulBody())
} finally {
connection.disconnect()
}
}
fun download(jobId: String, destination: File) {
val connection = open("v1/audiobooks/$jobId/file").apply {
readTimeout = DOWNLOAD_TIMEOUT_MS
}
try {
if (connection.responseCode !in 200..299) {
throw IOException(connection.errorDescription())
}
destination.parentFile?.mkdirs()
connection.inputStream.buffered(256 * 1024).use { input ->
FileOutputStream(destination).buffered(256 * 1024).use { output ->
input.copyTo(output, 256 * 1024)
}
}
check(destination.isFile && destination.length() > 0L) {
"Сервер вернул пустой M4A-файл"
}
} finally {
connection.disconnect()
}
}
fun cancelOrDelete(jobId: String) {
val connection = open("v1/audiobooks/$jobId").apply { requestMethod = "DELETE" }
try {
val code = connection.responseCode
if (code !in 200..299 && code != HttpURLConnection.HTTP_NOT_FOUND) {
throw IOException(connection.errorDescription())
}
(if (code in 200..299) connection.inputStream else connection.errorStream)?.close()
} finally {
connection.disconnect()
}
}
private fun open(path: String): HttpURLConnection {
check(token.isNotBlank()) { "Ключ сервера аудиокниг не добавлен в сборку" }
return (URL(baseUrl.trimEnd('/') + "/" + path).openConnection() as HttpURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
useCaches = false
setRequestProperty("Authorization", "Bearer $token")
setRequestProperty("Accept", "application/json")
}
}
private fun HttpURLConnection.readSuccessfulBody(): String {
val code = responseCode
if (code !in 200..299) throw IOException(errorDescription())
return inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
}
private fun HttpURLConnection.errorDescription(): String {
val details = errorStream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() }.orEmpty()
return "Сервер аудиокниг вернул HTTP $responseCode" +
details.takeIf(String::isNotBlank)?.let { ": $it" }.orEmpty()
}
private fun parseJob(payload: String): RemoteJob {
val json = JSONObject(payload)
return RemoteJob(
id = json.getString("id"),
status = json.getString("status"),
stage = json.optString("stage"),
processedCharacters = json.optLong("processedCharacters"),
totalCharacters = json.optLong("totalCharacters"),
currentChapter = json.optString("currentChapter").takeIf { it.isNotBlank() && it != "null" },
durationMs = json.optLong("durationMs"),
chaptersJson = json.optJSONArray("chapters")?.toString() ?: "[]",
error = json.optString("error").takeIf { it.isNotBlank() && it != "null" }
)
}
private fun BufferedOutputStream.writeTextPart(boundary: String, name: String, value: String) {
writeAscii("--$boundary\r\n")
writeAscii("Content-Disposition: form-data; name=\"$name\"\r\n")
writeAscii("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
write(value.toByteArray(StandardCharsets.UTF_8))
writeAscii("\r\n")
}
private fun BufferedOutputStream.writeAscii(value: String) {
write(value.toByteArray(StandardCharsets.US_ASCII))
}
private fun contentType(file: File): String = when (file.extension.lowercase()) {
"epub" -> "application/epub+zip"
"fb2" -> "application/x-fictionbook+xml"
else -> "application/octet-stream"
}
data class RemoteJob(
val id: String,
val status: String,
val stage: String,
val processedCharacters: Long,
val totalCharacters: Long,
val currentChapter: String?,
val durationMs: Long,
val chaptersJson: String,
val error: String?
)
private companion object {
const val CONNECT_TIMEOUT_MS = 30_000
const val READ_TIMEOUT_MS = 60_000
const val DOWNLOAD_TIMEOUT_MS = 10 * 60_000
}
}
@@ -5,6 +5,7 @@ import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import com.aletheia.app.model.AudioBook
import com.aletheia.app.model.Book
import com.aletheia.app.model.ReadingBookmark
import com.aletheia.app.model.ReadingNote
@@ -61,6 +62,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
createBookmarksTable(db)
createNotesTable(db)
createAudioBooksTable(db)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -86,6 +88,12 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
db.execSQL("ALTER TABLE reading_notes ADD COLUMN kind TEXT NOT NULL DEFAULT 'note'")
}
}
if (oldVersion < 7) {
createAudioBooksTable(db)
}
if (oldVersion < 8 && !hasColumn(db, "audiobooks", "remote_job_id")) {
db.execSQL("ALTER TABLE audiobooks ADD COLUMN remote_job_id TEXT")
}
}
fun getAllBooks(): List<Book> = readableDatabase.query(
@@ -133,6 +141,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
writableDatabase.delete("reading_progress", "book_id = ?", arrayOf(book.id.toString()))
writableDatabase.delete("reading_bookmarks", "book_id = ?", arrayOf(book.id.toString()))
writableDatabase.delete("reading_notes", "book_id = ?", arrayOf(book.id.toString()))
writableDatabase.delete("audiobooks", "source_book_id = ?", arrayOf(book.id.toString()))
writableDatabase.delete("books", "id = ?", arrayOf(book.id.toString()))
writableDatabase.setTransactionSuccessful()
} finally {
@@ -153,6 +162,34 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
if (cursor.moveToFirst()) cursor.getString(0) else null
}
fun getAllAudioBooks(): List<AudioBook> = readableDatabase.query(
"audiobooks", null, null, null, null, null, "updated_at DESC"
).use { cursor ->
buildList { while (cursor.moveToNext()) add(cursorToAudioBook(cursor)) }
}
fun getAudioBookById(id: Long): AudioBook? = readableDatabase.query(
"audiobooks", null, "id = ?", arrayOf(id.toString()), null, null, null, "1"
).use { cursor -> if (cursor.moveToFirst()) cursorToAudioBook(cursor) else null }
fun getAudioBookBySourceBookId(bookId: Long): AudioBook? = readableDatabase.query(
"audiobooks", null, "source_book_id = ?", arrayOf(bookId.toString()), null, null, null, "1"
).use { cursor -> if (cursor.moveToFirst()) cursorToAudioBook(cursor) else null }
fun saveAudioBook(audioBook: AudioBook): Long {
val values = audioBookToValues(audioBook)
return if (audioBook.id == 0L) {
writableDatabase.insertWithOnConflict("audiobooks", null, values, SQLiteDatabase.CONFLICT_REPLACE)
} else {
writableDatabase.update("audiobooks", values, "id = ?", arrayOf(audioBook.id.toString()))
audioBook.id
}
}
fun deleteAudioBook(id: Long) {
writableDatabase.delete("audiobooks", "id = ?", arrayOf(id.toString()))
}
fun getSettings(keys: Collection<String>): Map<String, String> {
if (keys.isEmpty()) return emptyMap()
val orderedKeys = keys.distinct()
@@ -368,6 +405,24 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
put("remote_id", book.remoteId)
}
private fun audioBookToValues(audioBook: AudioBook) = ContentValues().apply {
put("source_book_id", audioBook.sourceBookId)
put("title", audioBook.title)
put("author", audioBook.author)
put("file_path", audioBook.filePath)
put("remote_job_id", audioBook.remoteJobId)
put("status", audioBook.status.databaseValue)
put("processed_characters", audioBook.processedCharacters)
put("total_characters", audioBook.totalCharacters)
put("duration_ms", audioBook.durationMs)
put("position_ms", audioBook.positionMs)
put("chapters_json", audioBook.chaptersJson)
put("current_chapter", audioBook.currentChapter)
put("error_message", audioBook.errorMessage)
put("created_at", audioBook.createdAt)
put("updated_at", audioBook.updatedAt)
}
private fun cursorToBook(cursor: Cursor) = Book(
id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
title = cursor.getString(cursor.getColumnIndexOrThrow("title")),
@@ -387,6 +442,25 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
remoteId = cursor.getString(cursor.getColumnIndexOrThrow("remote_id"))
)
private fun cursorToAudioBook(cursor: Cursor) = AudioBook(
id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
sourceBookId = cursor.getLong(cursor.getColumnIndexOrThrow("source_book_id")),
title = cursor.getString(cursor.getColumnIndexOrThrow("title")),
author = cursor.getString(cursor.getColumnIndexOrThrow("author")),
filePath = cursor.getString(cursor.getColumnIndexOrThrow("file_path")),
remoteJobId = cursor.getString(cursor.getColumnIndexOrThrow("remote_job_id")),
status = AudioBook.Status.fromDatabase(cursor.getString(cursor.getColumnIndexOrThrow("status"))),
processedCharacters = cursor.getLong(cursor.getColumnIndexOrThrow("processed_characters")),
totalCharacters = cursor.getLong(cursor.getColumnIndexOrThrow("total_characters")),
durationMs = cursor.getLong(cursor.getColumnIndexOrThrow("duration_ms")),
positionMs = cursor.getLong(cursor.getColumnIndexOrThrow("position_ms")),
chaptersJson = cursor.getString(cursor.getColumnIndexOrThrow("chapters_json")),
currentChapter = cursor.getString(cursor.getColumnIndexOrThrow("current_chapter")),
errorMessage = cursor.getString(cursor.getColumnIndexOrThrow("error_message")),
createdAt = cursor.getLong(cursor.getColumnIndexOrThrow("created_at")),
updatedAt = cursor.getLong(cursor.getColumnIndexOrThrow("updated_at"))
)
private fun cursorToBookmark(cursor: Cursor) = ReadingBookmark(
id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
bookId = cursor.getLong(cursor.getColumnIndexOrThrow("book_id")),
@@ -474,6 +548,37 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
}
}
private fun createAudioBooksTable(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS audiobooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_book_id INTEGER NOT NULL UNIQUE,
title TEXT NOT NULL,
author TEXT NOT NULL,
file_path TEXT NOT NULL DEFAULT '',
remote_job_id TEXT,
status TEXT NOT NULL DEFAULT 'queued',
processed_characters INTEGER NOT NULL DEFAULT 0,
total_characters INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
position_ms INTEGER NOT NULL DEFAULT 0,
chapters_json TEXT NOT NULL DEFAULT '[]',
current_chapter TEXT,
error_message TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
""".trimIndent()
)
db.execSQL(
"""
CREATE INDEX IF NOT EXISTS idx_audiobooks_status
ON audiobooks(status, updated_at DESC)
""".trimIndent()
)
}
private fun hasColumn(db: SQLiteDatabase, tableName: String, columnName: String): Boolean {
db.rawQuery("PRAGMA table_info($tableName)", null).use { cursor ->
while (cursor.moveToNext()) {
@@ -487,7 +592,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
companion object {
private const val DATABASE_NAME = "aletheia.db3"
private const val DATABASE_VERSION = 6
private const val DATABASE_VERSION = 8
private const val MAX_READING_HISTORY_ENTRIES = 200
}
}
@@ -0,0 +1,72 @@
package com.aletheia.app.data
import android.content.Context
import com.aletheia.app.model.AudioBook
import com.aletheia.app.model.Book
import java.io.File
class AudioBookRepository(
context: Context,
private val databaseHelper: AppDatabaseHelper
) {
private val outputDirectory = File(context.cacheDir, "audiobooks").apply { mkdirs() }
fun getAll(): List<AudioBook> = databaseHelper.getAllAudioBooks().map(::reconcileFile)
fun getById(id: Long): AudioBook? = databaseHelper.getAudioBookById(id)?.let(::reconcileFile)
fun getBySourceBookId(bookId: Long): AudioBook? =
databaseHelper.getAudioBookBySourceBookId(bookId)?.let(::reconcileFile)
fun createOrReset(book: Book): AudioBook {
val existing = databaseHelper.getAudioBookBySourceBookId(book.id)
existing?.filePath?.takeIf(String::isNotBlank)?.let { File(it).delete() }
val now = System.currentTimeMillis()
val audioBook = AudioBook(
id = existing?.id ?: 0,
sourceBookId = book.id,
title = book.title,
author = book.author,
remoteJobId = null,
status = AudioBook.Status.QUEUED,
createdAt = existing?.createdAt ?: now,
updatedAt = now
)
val id = databaseHelper.saveAudioBook(audioBook)
return requireNotNull(databaseHelper.getAudioBookById(id))
}
fun save(audioBook: AudioBook): AudioBook {
val id = databaseHelper.saveAudioBook(audioBook.copy(updatedAt = System.currentTimeMillis()))
return requireNotNull(databaseHelper.getAudioBookById(id))
}
fun savePosition(id: Long, positionMs: Long) {
val current = databaseHelper.getAudioBookById(id) ?: return
databaseHelper.saveAudioBook(
current.copy(positionMs = positionMs.coerceAtLeast(0), updatedAt = System.currentTimeMillis())
)
}
fun outputFile(audioBookId: Long): File = File(outputDirectory, "audiobook-$audioBookId.m4a")
fun partialOutputFile(audioBookId: Long): File = File(outputDirectory, "audiobook-$audioBookId.partial.m4a")
fun delete(audioBook: AudioBook) {
audioBook.filePath.takeIf(String::isNotBlank)?.let { File(it).delete() }
partialOutputFile(audioBook.id).delete()
databaseHelper.deleteAudioBook(audioBook.id)
}
private fun reconcileFile(audioBook: AudioBook): AudioBook {
if (audioBook.status != AudioBook.Status.READY || File(audioBook.filePath).isFile) {
return audioBook
}
return save(
audioBook.copy(
status = AudioBook.Status.FAILED,
errorMessage = "Файл аудиокниги удалён системой из кэша. Создайте его повторно."
)
)
}
}
@@ -0,0 +1,34 @@
package com.aletheia.app.model
data class AudioBook(
val id: Long = 0,
val sourceBookId: Long,
val title: String,
val author: String,
val filePath: String = "",
val remoteJobId: String? = null,
val status: Status = Status.QUEUED,
val processedCharacters: Long = 0,
val totalCharacters: Long = 0,
val durationMs: Long = 0,
val positionMs: Long = 0,
val chaptersJson: String = "[]",
val currentChapter: String? = null,
val errorMessage: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
) {
val progressPercent: Int
get() = if (totalCharacters <= 0) 0 else
((processedCharacters * 100L) / totalCharacters).toInt().coerceIn(0, 100)
enum class Status(val databaseValue: String) {
QUEUED("queued"), GENERATING("generating"), READY("ready"),
FAILED("failed"), CANCELLED("cancelled");
companion object {
fun fromDatabase(value: String?): Status =
entries.firstOrNull { it.databaseValue == value } ?: FAILED
}
}
}
@@ -0,0 +1,263 @@
package com.aletheia.app.ui.audiobook
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.SeekBar
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.PopupMenu
import androidx.lifecycle.lifecycleScope
import com.aletheia.app.AletheiaApplication
import com.aletheia.app.R
import com.aletheia.app.databinding.ActivityAudioBookPlayerBinding
import com.aletheia.app.model.AudioBook
import com.aletheia.app.ui.reader.ReaderActivity
import com.aletheia.app.util.setBookCover
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.File
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
class AudioBookPlayerActivity : AppCompatActivity() {
private lateinit var binding: ActivityAudioBookPlayerBinding
private val app by lazy { application as AletheiaApplication }
private val handler = Handler(Looper.getMainLooper())
private var mediaPlayer: MediaPlayer? = null
private var audioBook: AudioBook? = null
private var chapters: List<Chapter> = emptyList()
private var prepared = false
private var userSeeking = false
private var speedIndex = 1
private var sleepAtMs: Long? = null
private val progressUpdater = object : Runnable {
override fun run() {
updateProgress()
handler.postDelayed(this, 500)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAudioBookPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
setupControls()
val id = intent.getLongExtra(EXTRA_AUDIOBOOK_ID, 0L)
if (id <= 0) finish() else loadAudioBook(id)
}
override fun onResume() {
super.onResume()
handler.post(progressUpdater)
}
override fun onPause() {
savePosition()
handler.removeCallbacks(progressUpdater)
super.onPause()
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
mediaPlayer?.release()
mediaPlayer = null
super.onDestroy()
}
private fun setupControls() {
binding.playerBackButton.setOnClickListener { finish() }
binding.playerPlayButton.setOnClickListener { togglePlayback() }
binding.playerRewindButton.setOnClickListener { seekBy(-15_000) }
binding.playerForwardButton.setOnClickListener { seekBy(30_000) }
binding.playerSpeedButton.setOnClickListener { cycleSpeed() }
binding.playerSleepButton.setOnClickListener { showSleepTimer() }
binding.playerReadButton.setOnClickListener {
audioBook?.let { startActivity(Intent(this, ReaderActivity::class.java).putExtra(ReaderActivity.EXTRA_BOOK_ID, it.sourceBookId)) }
}
binding.playerMoreButton.setOnClickListener { anchor ->
PopupMenu(this, anchor).apply {
menu.add("Начать сначала").setOnMenuItemClickListener {
mediaPlayer?.seekTo(0)
updateProgress()
true
}
menu.add("Открыть электронную книгу").setOnMenuItemClickListener {
binding.playerReadButton.performClick()
true
}
show()
}
}
binding.playerSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onStartTrackingTouch(seekBar: SeekBar?) { userSeeking = true }
override fun onStopTrackingTouch(seekBar: SeekBar?) {
val duration = mediaPlayer?.duration?.takeIf { it > 0 } ?: 0
mediaPlayer?.seekTo(duration * (seekBar?.progress ?: 0) / 1000)
userSeeking = false
updateProgress()
}
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) = Unit
})
}
private fun loadAudioBook(id: Long) {
lifecycleScope.launch {
val data = withContext(Dispatchers.IO) {
val audio = app.audioBookRepository.getById(id)
val book = audio?.let { app.bookRepository.getBookById(it.sourceBookId) }
audio to book
}
val audio = data.first
if (audio == null || audio.status != AudioBook.Status.READY || !File(audio.filePath).isFile) {
Toast.makeText(this@AudioBookPlayerActivity, "Файл аудиокниги недоступен", Toast.LENGTH_LONG).show()
finish()
return@launch
}
audioBook = audio
chapters = parseChapters(audio.chaptersJson)
binding.playerTitle.text = audio.title
binding.playerAuthor.text = audio.author
binding.playerCover.setBookCover(data.second?.coverImage, R.drawable.default_cover)
preparePlayer(audio)
}
}
private fun preparePlayer(audio: AudioBook) {
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
setDataSource(audio.filePath)
setOnPreparedListener { player ->
prepared = true
player.seekTo(audio.positionMs.coerceAtMost(player.duration.toLong()).toInt())
applySpeed()
updateProgress()
}
setOnCompletionListener {
binding.playerPlayButton.text = ""
updateProgress()
savePosition()
}
setOnErrorListener { _, _, _ ->
Toast.makeText(this@AudioBookPlayerActivity, "Не удалось воспроизвести M4A-файл", Toast.LENGTH_LONG).show()
true
}
prepareAsync()
}
}
private fun togglePlayback() {
val player = mediaPlayer ?: return
if (!prepared) return
if (player.isPlaying) player.pause() else player.start()
binding.playerPlayButton.text = if (player.isPlaying) "" else ""
}
private fun seekBy(deltaMs: Int) {
val player = mediaPlayer ?: return
if (!prepared) return
player.seekTo((player.currentPosition + deltaMs).coerceIn(0, player.duration))
updateProgress()
}
private fun cycleSpeed() {
speedIndex = (speedIndex + 1) % SPEEDS.size
applySpeed()
}
private fun applySpeed() {
val speed = SPEEDS[speedIndex]
binding.playerSpeedButton.text = "${speed.toDisplay()}x"
if (prepared) runCatching {
mediaPlayer?.playbackParams = mediaPlayer!!.playbackParams.setSpeed(speed)
}
}
private fun showSleepTimer() {
val labels = arrayOf("Выключен", "15 минут", "30 минут", "45 минут", "60 минут")
val minutes = intArrayOf(0, 15, 30, 45, 60)
MaterialAlertDialogBuilder(this)
.setTitle("Таймер сна")
.setItems(labels) { _, index ->
sleepAtMs = minutes[index].takeIf { it > 0 }?.let { System.currentTimeMillis() + it * 60_000L }
binding.playerSleepButton.alpha = if (sleepAtMs == null) 1f else 0.45f
}
.show()
}
private fun updateProgress() {
val player = mediaPlayer ?: return
if (!prepared) return
sleepAtMs?.let { deadline ->
if (System.currentTimeMillis() >= deadline) {
if (player.isPlaying) player.pause()
binding.playerPlayButton.text = ""
sleepAtMs = null
binding.playerSleepButton.alpha = 1f
}
}
val position = player.currentPosition.coerceAtLeast(0)
val duration = player.duration.coerceAtLeast(1)
if (!userSeeking) binding.playerSeekBar.progress = position * 1000 / duration
binding.playerElapsed.text = formatTime(position.toLong())
binding.playerRemaining.text = "-${formatTime((duration - position).toLong())}"
binding.playerRemainingSummary.text = remainingSummary((duration - position).toLong())
binding.playerChapter.text = chapters.lastOrNull { it.startMs <= position }?.title ?: "Вся книга"
}
private fun savePosition() {
val id = audioBook?.id ?: return
val position = mediaPlayer?.currentPosition?.toLong() ?: return
app.applicationScope.launch { app.audioBookRepository.savePosition(id, position) }
}
private fun parseChapters(json: String): List<Chapter> = runCatching {
val array = JSONArray(json)
buildList {
for (index in 0 until array.length()) {
val item = array.getJSONObject(index)
add(Chapter(item.getString("title"), item.getLong("startMs")))
}
}
}.getOrDefault(emptyList())
private fun remainingSummary(milliseconds: Long): String {
val totalMinutes = milliseconds.coerceAtLeast(0) / 60_000
val hours = totalMinutes / 60
val minutes = totalMinutes % 60
return if (hours > 0) "$hours ч $minutes мин до конца книги" else "$minutes мин до конца книги"
}
private fun formatTime(milliseconds: Long): String {
val seconds = milliseconds.coerceAtLeast(0) / 1000
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val rest = seconds % 60
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, rest) else "%02d:%02d".format(minutes, rest)
}
private fun Float.toDisplay(): String = if (this % 1f == 0f) toInt().toString() else
String.format(Locale.US, "%.2f", this).trimEnd('0').trimEnd('.')
private data class Chapter(val title: String, val startMs: Long)
companion object {
private const val EXTRA_AUDIOBOOK_ID = "audio_book_id"
private val SPEEDS = floatArrayOf(0.75f, 1f, 1.25f, 1.5f, 2f)
fun createIntent(context: Context, audioBookId: Long) =
Intent(context, AudioBookPlayerActivity::class.java).putExtra(EXTRA_AUDIOBOOK_ID, audioBookId)
}
}
@@ -8,71 +8,66 @@ import androidx.recyclerview.widget.RecyclerView
import com.aletheia.app.R
import com.aletheia.app.databinding.ItemBookBinding
import com.aletheia.app.databinding.ItemShelfAddBinding
import com.aletheia.app.model.AudioBook
import com.aletheia.app.model.Book
import com.aletheia.app.util.setBookCover
class BooksAdapter(
private val onBookClicked: (Book) -> Unit,
private val onAudioBookClicked: (AudioBook) -> Unit = {},
private val onAddBookClicked: () -> Unit,
private val onBookLongPressed: ((Book) -> Unit)? = null,
private val onBookActionsClicked: ((View, Book) -> Unit)? = null,
private val onAudioBookActionsClicked: ((View, AudioBook) -> Unit)? = null,
private val includeAddItem: Boolean = true
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val items = mutableListOf<Book>()
private val items = mutableListOf<ShelfItem>()
init {
setHasStableIds(true)
init { setHasStableIds(true) }
fun submitList(books: List<Book>) = submitContent(books, emptyList())
fun updateBook(book: Book) {
val currentBooks = items.mapNotNull { (it as? ShelfItem.TextBook)?.book }.map {
if (it.id == book.id) book else it
}
val currentAudio = items.mapNotNull { it as? ShelfItem.Audio }.map { it.audioBook to it.coverImage }
submitContent(currentBooks, currentAudio)
}
fun submitList(books: List<Book>) {
val oldItems = items.toList()
fun submitContent(books: List<Book>, audioBooks: List<Pair<AudioBook, ByteArray?>>) {
val next = books.map(ShelfItem::TextBook) + audioBooks.map { (audioBook, cover) ->
ShelfItem.Audio(audioBook, cover)
}
val old = items.toList()
val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldItems.shelfItemCount()
override fun getNewListSize(): Int = books.shelfItemCount()
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldIsAddItem = oldItemPosition >= oldItems.size
val newIsAddItem = newItemPosition >= books.size
return when {
oldIsAddItem && newIsAddItem -> true
oldIsAddItem || newIsAddItem -> false
else -> oldItems[oldItemPosition].id == books[newItemPosition].id
}
override fun getOldListSize() = old.itemCount()
override fun getNewListSize() = next.itemCount()
override fun areItemsTheSame(oldPosition: Int, newPosition: Int): Boolean {
val oldItem = old.getOrNull(oldPosition)
val newItem = next.getOrNull(newPosition)
return if (oldItem == null || newItem == null) oldItem == newItem else oldItem.stableId == newItem.stableId
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldIsAddItem = oldItemPosition >= oldItems.size
val newIsAddItem = newItemPosition >= books.size
return when {
oldIsAddItem && newIsAddItem -> true
oldIsAddItem || newIsAddItem -> false
else -> oldItems[oldItemPosition].hasSameVisibleContent(books[newItemPosition])
}
override fun areContentsTheSame(oldPosition: Int, newPosition: Int): Boolean {
val oldItem = old.getOrNull(oldPosition)
val newItem = next.getOrNull(newPosition)
return if (oldItem == null || newItem == null) oldItem == newItem else oldItem.sameVisibleContent(newItem)
}
})
items.clear()
items.addAll(books)
items.addAll(next)
diff.dispatchUpdatesTo(this)
}
fun updateBook(book: Book) {
val index = items.indexOfFirst { it.id == book.id }
if (index < 0) return
items[index] = book
notifyItemChanged(index)
}
override fun getItemViewType(position: Int): Int =
if (position < items.size) VIEW_TYPE_BOOK else VIEW_TYPE_ADD
override fun getItemViewType(position: Int) = if (position < items.size) VIEW_TYPE_BOOK else VIEW_TYPE_ADD
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_BOOK -> BookViewHolder(ItemBookBinding.inflate(inflater, parent, false))
VIEW_TYPE_ADD -> AddBookViewHolder(ItemShelfAddBinding.inflate(inflater, parent, false))
else -> error("Unsupported bookshelf item view type: $viewType")
return if (viewType == VIEW_TYPE_BOOK) {
BookViewHolder(ItemBookBinding.inflate(inflater, parent, false))
} else {
AddBookViewHolder(ItemShelfAddBinding.inflate(inflater, parent, false))
}
}
@@ -83,72 +78,88 @@ class BooksAdapter(
}
}
override fun getItemCount(): Int = items.shelfItemCount()
override fun getItemId(position: Int): Long =
if (position < items.size) items[position].id else ADD_ITEM_ID
override fun getItemCount() = items.itemCount()
override fun getItemId(position: Int) = items.getOrNull(position)?.stableId ?: ADD_ITEM_ID
private inner class BookViewHolder(private val binding: ItemBookBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(book: Book) {
fun bind(item: ShelfItem) {
val context = binding.root.context
binding.bookTitle.text = book.title
binding.bookAuthor.text = book.author
binding.bookProgressText.text = book.progressText
binding.bookProgressBar.progress = (book.readingProgress * 100).toInt()
binding.bookCover.setBookCover(book.coverImage, R.drawable.default_cover)
binding.root.contentDescription = context.getString(
R.string.a11y_book_card,
book.title,
book.author,
book.progressText
)
binding.root.setOnClickListener { onBookClicked(book) }
if (onBookLongPressed == null) {
binding.root.setOnLongClickListener(null)
binding.root.isLongClickable = false
} else {
binding.root.setOnLongClickListener {
onBookLongPressed.invoke(book)
true
when (item) {
is ShelfItem.TextBook -> {
val book = item.book
binding.bookTitle.text = book.title
binding.bookAuthor.text = book.author
binding.bookProgressText.text = book.progressText
binding.bookProgressBar.progress = (book.readingProgress * 100).toInt()
binding.bookCover.setBookCover(book.coverImage, R.drawable.default_cover)
binding.root.contentDescription = context.getString(
R.string.a11y_book_card, book.title, book.author, book.progressText
)
binding.root.setOnClickListener { onBookClicked(book) }
binding.root.setOnLongClickListener(if (onBookLongPressed == null) null else View.OnLongClickListener {
onBookLongPressed.invoke(book)
true
})
bindActions { anchor -> onBookActionsClicked?.invoke(anchor, book) }
}
}
if (onBookActionsClicked == null) {
binding.bookActions.visibility = View.GONE
binding.bookActions.setOnClickListener(null)
} else {
binding.bookActions.visibility = View.VISIBLE
binding.bookActions.contentDescription = context.getString(R.string.a11y_book_actions, book.title)
binding.bookActions.setOnClickListener { anchor ->
onBookActionsClicked.invoke(anchor, book)
is ShelfItem.Audio -> {
val audio = item.audioBook
val progress = when {
audio.status == AudioBook.Status.GENERATING || audio.status == AudioBook.Status.QUEUED ->
audio.progressPercent
audio.durationMs > 0 -> ((audio.positionMs * 100L) / audio.durationMs).toInt().coerceIn(0, 100)
else -> 0
}
val status = when (audio.status) {
AudioBook.Status.READY -> "Аудиокнига · $progress%"
AudioBook.Status.GENERATING -> "Создание · ${audio.progressPercent}%"
AudioBook.Status.QUEUED -> "В очереди на создание"
AudioBook.Status.FAILED -> "Ошибка создания"
AudioBook.Status.CANCELLED -> "Создание отменено"
}
binding.bookTitle.text = audio.title
binding.bookAuthor.text = audio.author
binding.bookProgressText.text = status
binding.bookProgressBar.progress = progress
binding.bookCover.setBookCover(item.coverImage, R.drawable.default_cover)
binding.root.contentDescription = "${audio.title}. ${audio.author}. $status"
binding.root.setOnLongClickListener(null)
binding.root.setOnClickListener { onAudioBookClicked(audio) }
bindActions { anchor -> onAudioBookActionsClicked?.invoke(anchor, audio) }
}
}
}
private fun bindActions(action: ((View) -> Unit)?) {
binding.bookActions.visibility = if (action == null) View.GONE else View.VISIBLE
binding.bookActions.setOnClickListener(action)
}
}
private inner class AddBookViewHolder(private val binding: ItemShelfAddBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind() {
binding.root.setOnClickListener { onAddBookClicked() }
fun bind() { binding.root.setOnClickListener { onAddBookClicked() } }
}
private sealed class ShelfItem(val stableId: Long) {
data class TextBook(val book: Book) : ShelfItem(book.id)
data class Audio(val audioBook: AudioBook, val coverImage: ByteArray?) :
ShelfItem(Long.MIN_VALUE + audioBook.id)
fun sameVisibleContent(other: ShelfItem?): Boolean = when {
this is TextBook && other is TextBook -> book.copy(coverImage = null) == other.book.copy(coverImage = null) &&
bytesEqual(book.coverImage, other.book.coverImage)
this is Audio && other is Audio -> audioBook == other.audioBook && bytesEqual(coverImage, other.coverImage)
else -> false
}
private fun bytesEqual(first: ByteArray?, second: ByteArray?) = when {
first === second -> true
first == null || second == null -> false
else -> first.contentEquals(second)
}
}
private fun Book.hasSameVisibleContent(other: Book): Boolean =
title == other.title &&
author == other.author &&
readingProgress == other.readingProgress &&
currentPage == other.currentPage &&
totalPages == other.totalPages &&
lastRead == other.lastRead &&
coverImage.bytesEqual(other.coverImage)
private fun ByteArray?.bytesEqual(other: ByteArray?): Boolean = when {
this === other -> true
this == null || other == null -> false
else -> contentEquals(other)
}
private fun List<Book>.shelfItemCount(): Int =
if (isEmpty()) 0 else size + if (includeAddItem) 1 else 0
private fun List<ShelfItem>.itemCount() = if (isEmpty()) 0 else size + if (includeAddItem) 1 else 0
private companion object {
const val VIEW_TYPE_BOOK = 1
const val VIEW_TYPE_ADD = 2
@@ -1,10 +1,14 @@
package com.aletheia.app.ui.books
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
@@ -23,10 +27,13 @@ import com.aletheia.app.R
import com.aletheia.app.data.Result
import com.aletheia.app.databinding.FragmentBookshelfBinding
import com.aletheia.app.model.Book
import com.aletheia.app.model.AudioBook
import com.aletheia.app.model.QBooksBook
import com.aletheia.app.ui.main.MainActivity
import com.aletheia.app.ui.qbooks.BookDetailActivity
import com.aletheia.app.ui.reader.ReaderActivity
import com.aletheia.app.audiobook.AudioBookGenerationService
import com.aletheia.app.ui.audiobook.AudioBookPlayerActivity
import com.aletheia.app.util.BookSharing
import com.aletheia.app.util.setBookCover
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -46,11 +53,22 @@ class BookshelfFragment : Fragment() {
private lateinit var qBooksShelfAdapter: QBooksShelfAdapter
private var currentBooks: List<Book> = emptyList()
private var visibleBooks: List<Book> = emptyList()
private var currentAudioBooks: List<AudioBook> = emptyList()
private var visibleAudioBooks: List<AudioBook> = emptyList()
private var continueReadingBook: Book? = null
private val metadataJobs = mutableListOf<Job>()
private var loadBooksJob: Job? = null
private var libraryFilter = LibraryFilter.ALL
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { }
private val generationReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioBookGenerationService.ACTION_PROGRESS && _binding != null) loadBooks()
}
}
private val importBookLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) {
return@registerForActivityResult
@@ -83,9 +101,11 @@ class BookshelfFragment : Fragment() {
booksAdapter = BooksAdapter(
onBookClicked = ::openBook,
onAudioBookClicked = ::openAudioBook,
onAddBookClicked = ::launchImport,
onBookLongPressed = ::confirmDeleteBook,
onBookActionsClicked = ::showBookActions
onBookActionsClicked = ::showBookActions,
onAudioBookActionsClicked = ::showAudioBookActions
)
binding.booksRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
binding.booksRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8)))
@@ -150,6 +170,20 @@ class BookshelfFragment : Fragment() {
loadBooks()
}
override fun onStart() {
super.onStart()
ContextCompat.registerReceiver(
requireContext(), generationReceiver,
IntentFilter(AudioBookGenerationService.ACTION_PROGRESS),
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
override fun onStop() {
runCatching { requireContext().unregisterReceiver(generationReceiver) }
super.onStop()
}
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
if (!hidden && _binding != null) {
@@ -169,14 +203,17 @@ class BookshelfFragment : Fragment() {
private fun loadBooks() {
loadBooksJob?.cancel()
loadBooksJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val result = runCatching { app.bookRepository.getAllBooks() }
val result = runCatching {
app.bookRepository.getAllBooks() to app.audioBookRepository.getAll()
}
withContext(Dispatchers.Main) {
if (_binding == null) {
return@withContext
}
result.onSuccess { books ->
result.onSuccess { (books, audioBooks) ->
currentBooks = books
currentAudioBooks = audioBooks
renderFilteredBooks()
enrichMissingMetadata(books.take(METADATA_PREFETCH_LIMIT))
}.onFailure { exception ->
@@ -205,15 +242,26 @@ class BookshelfFragment : Fragment() {
private fun renderFilteredBooks() {
val query = binding.booksSearchInput.text?.toString().orEmpty().trim()
visibleBooks = filterBooks(currentBooks, query)
booksAdapter.submitList(visibleBooks)
visibleAudioBooks = filterAudioBooks(currentAudioBooks, query)
val covers = currentBooks.associate { it.id to it.coverImage }
booksAdapter.submitContent(
visibleBooks,
visibleAudioBooks.map { it to covers[it.sourceBookId] }
)
renderLibraryState(
allBooks = currentBooks,
displayedBooks = visibleBooks,
displayedAudioBooks = visibleAudioBooks,
query = query
)
}
private fun renderLibraryState(allBooks: List<Book>, displayedBooks: List<Book>, query: String) {
private fun renderLibraryState(
allBooks: List<Book>,
displayedBooks: List<Book>,
displayedAudioBooks: List<AudioBook>,
query: String
) {
val isFiltered = query.isNotBlank() || libraryFilter != LibraryFilter.ALL
continueReadingBook = if (isFiltered) {
null
@@ -225,8 +273,8 @@ class BookshelfFragment : Fragment() {
}
val isLibraryEmpty = allBooks.isEmpty()
val isSearchEmpty = !isLibraryEmpty && displayedBooks.isEmpty()
val hasVisibleBooks = displayedBooks.isNotEmpty()
val isSearchEmpty = !isLibraryEmpty && displayedBooks.isEmpty() && displayedAudioBooks.isEmpty()
val hasVisibleBooks = displayedBooks.isNotEmpty() || displayedAudioBooks.isNotEmpty()
binding.emptyLibraryCard.visibility = if (isLibraryEmpty) View.VISIBLE else View.GONE
binding.emptySearchCard.visibility = if (isSearchEmpty) View.VISIBLE else View.GONE
binding.bookshelfPanel.visibility = if (hasVisibleBooks) View.VISIBLE else View.GONE
@@ -318,6 +366,24 @@ class BookshelfFragment : Fragment() {
}
}
private fun filterAudioBooks(audioBooks: List<AudioBook>, query: String): List<AudioBook> {
val search = query.lowercase().trim()
return audioBooks.filter { audioBook ->
val matchesSearch = search.isBlank() ||
audioBook.title.lowercase().contains(search) ||
audioBook.author.lowercase().contains(search)
val matchesFilter = when (libraryFilter) {
LibraryFilter.ALL -> true
LibraryFilter.DOWNLOADED -> audioBook.status == AudioBook.Status.READY
LibraryFilter.READING -> audioBook.status == AudioBook.Status.READY &&
audioBook.positionMs > 0 && audioBook.positionMs < audioBook.durationMs
LibraryFilter.FINISHED -> audioBook.status == AudioBook.Status.READY &&
audioBook.durationMs > 0 && audioBook.positionMs >= audioBook.durationMs - 2_000
}
matchesSearch && matchesFilter
}
}
private enum class LibraryFilter {
ALL,
READING,
@@ -331,6 +397,21 @@ class BookshelfFragment : Fragment() {
startActivity(Intent(requireContext(), ReaderActivity::class.java).putExtra(ReaderActivity.EXTRA_BOOK_ID, book.id))
}
private fun openAudioBook(audioBook: AudioBook) {
when (audioBook.status) {
AudioBook.Status.READY -> startActivity(AudioBookPlayerActivity.createIntent(requireContext(), audioBook.id))
AudioBook.Status.GENERATING, AudioBook.Status.QUEUED -> showGenerationProgress(audioBook)
AudioBook.Status.FAILED, AudioBook.Status.CANCELLED -> MaterialAlertDialogBuilder(requireContext())
.setTitle("Аудиокнига не создана")
.setMessage(audioBook.errorMessage ?: "Создание было отменено. Можно запустить его повторно.")
.setPositiveButton("Создать снова") { _, _ ->
currentBooks.firstOrNull { it.id == audioBook.sourceBookId }?.let(::confirmCreateAudioBook)
}
.setNegativeButton(R.string.action_cancel, null)
.show()
}
}
private fun openQBooksBookPreview(book: QBooksBook) {
startActivity(BookDetailActivity.createIntent(requireContext(), book))
}
@@ -348,6 +429,10 @@ class BookshelfFragment : Fragment() {
inflate(R.menu.book_actions_menu)
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.action_create_audiobook -> {
confirmCreateAudioBook(book)
true
}
R.id.action_share_book -> {
shareBook(book)
true
@@ -363,6 +448,86 @@ class BookshelfFragment : Fragment() {
}
}
private fun showAudioBookActions(anchor: View, audioBook: AudioBook) {
PopupMenu(requireContext(), anchor).apply {
when (audioBook.status) {
AudioBook.Status.GENERATING, AudioBook.Status.QUEUED -> menu.add("Отменить создание").setOnMenuItemClickListener {
AudioBookGenerationService.cancel(requireContext(), audioBook.id)
true
}
else -> menu.add("Создать заново").setOnMenuItemClickListener {
currentBooks.firstOrNull { it.id == audioBook.sourceBookId }?.let(::confirmCreateAudioBook)
true
}
}
menu.add("Удалить аудиокнигу").setOnMenuItemClickListener {
confirmDeleteAudioBook(audioBook)
true
}
show()
}
}
private fun confirmCreateAudioBook(book: Book) {
val existing = currentAudioBooks.firstOrNull { it.sourceBookId == book.id }
val warning = if (existing?.status == AudioBook.Status.READY) {
"Существующий аудиофайл будет заменён. "
} else ""
MaterialAlertDialogBuilder(requireContext())
.setTitle("Создать аудиокнигу?")
.setMessage(
warning + "Книга будет отправлена на ваш CUDA-сервер и озвучена мужским голосом Qwen. " +
"Приложение покажет прогресс и скачает готовый M4A в свой кэш. Создание может занять продолжительное время."
)
.setPositiveButton("Начать") { _, _ -> startAudioBookGeneration(book) }
.setNegativeButton(R.string.action_cancel, null)
.show()
}
private fun startAudioBookGeneration(book: Book) {
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val record = app.audioBookRepository.createOrReset(book)
withContext(Dispatchers.Main) {
if (_binding == null) return@withContext
AudioBookGenerationService.start(requireContext(), record.id)
loadBooks()
}
}
}
private fun showGenerationProgress(audioBook: AudioBook) {
val chapter = audioBook.currentChapter?.let { "\nСейчас: $it" }.orEmpty()
MaterialAlertDialogBuilder(requireContext())
.setTitle("Создание аудиокниги · ${audioBook.progressPercent}%")
.setMessage("Обработано ${audioBook.processedCharacters} из ${audioBook.totalCharacters} символов.$chapter")
.setPositiveButton("Продолжить в фоне", null)
.setNegativeButton("Отменить") { _, _ ->
AudioBookGenerationService.cancel(requireContext(), audioBook.id)
}
.show()
}
private fun confirmDeleteAudioBook(audioBook: AudioBook) {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Удалить аудиокнигу?")
.setMessage("Исходная электронная книга останется в библиотеке.")
.setPositiveButton(R.string.action_delete) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
AudioBookGenerationService.cancel(requireContext(), audioBook.id)
app.audioBookRepository.delete(audioBook)
withContext(Dispatchers.Main) { if (_binding != null) loadBooks() }
}
}
.setNegativeButton(R.string.action_cancel, null)
.show()
}
private fun shareBook(book: Book) {
runCatching { BookSharing.shareDownloadedBook(requireContext(), book) }
.onFailure { exception ->
@@ -381,7 +546,13 @@ class BookshelfFragment : Fragment() {
private fun deleteBook(book: Book) {
lifecycleScope.launch(Dispatchers.IO) {
val result = runCatching { app.bookRepository.deleteBook(book) }
val result = runCatching {
app.audioBookRepository.getBySourceBookId(book.id)?.let { audioBook ->
AudioBookGenerationService.cancel(requireContext(), audioBook.id)
app.audioBookRepository.delete(audioBook)
}
app.bookRepository.deleteBook(book)
}
withContext(Dispatchers.Main) {
if (_binding == null) {
return@withContext
@@ -47,8 +47,6 @@ import com.aletheia.app.model.Book
import com.aletheia.app.model.ReaderChapterItem
import com.aletheia.app.model.ReadingBookmark
import com.aletheia.app.model.ReadingNote
import com.aletheia.app.ui.reader.tts.ReaderSpeechController
import com.aletheia.app.ui.reader.tts.ReaderSpeechSettingsDialog
import com.google.android.material.button.MaterialButton
import java.io.File
import kotlin.math.abs
@@ -67,7 +65,6 @@ import org.json.JSONObject
class ReaderActivity : AppCompatActivity() {
private lateinit var binding: ActivityReaderBinding
private lateinit var webController: ReaderWebController
private lateinit var speechController: ReaderSpeechController
private val app by lazy { application as AletheiaApplication }
private val paginationCache by lazy { ReaderPaginationCache(this) }
@@ -131,13 +128,6 @@ class ReaderActivity : AppCompatActivity() {
applyNativePreferences()
webController = ReaderWebController(this, binding.readerWebView, ::handleReaderEvent)
speechController = ReaderSpeechController(
context = this,
scope = lifecycleScope,
reader = webController,
onStateChanged = ::renderSpeechState,
onMessage = { message -> Toast.makeText(this, message, Toast.LENGTH_LONG).show() }
)
applyRequestedOrientation()
showLoading("Подготавливаю книгу…")
@@ -190,7 +180,6 @@ class ReaderActivity : AppCompatActivity() {
override fun onDestroy() {
try {
if (::speechController.isInitialized) speechController.destroy()
if (::webController.isInitialized) webController.destroy()
} finally {
super.onDestroy()
@@ -336,21 +325,6 @@ class ReaderActivity : AppCompatActivity() {
false
}
binding.readerBackButton.setOnClickListener { finishReader() }
binding.readerVoiceButton.setOnClickListener {
if (!bookReady) {
Toast.makeText(this, "Дождитесь загрузки книги", Toast.LENGTH_SHORT).show()
} else {
speechController.toggle()
}
}
binding.readerVoiceButton.setOnLongClickListener {
if (::speechController.isInitialized) {
ReaderSpeechSettingsDialog.show(this, speechController)
true
} else {
false
}
}
binding.readerSettingsButton.setOnClickListener { openSettings() }
binding.readerContentsButton.setOnClickListener { openContents() }
binding.readerBookmarkButton.setOnClickListener { toggleBookmark() }
@@ -513,17 +487,6 @@ class ReaderActivity : AppCompatActivity() {
}
}
private fun renderSpeechState(state: ReaderSpeechController.State) {
val active = state == ReaderSpeechController.State.PLAYING ||
state == ReaderSpeechController.State.INITIALIZING
binding.readerVoiceButton.isSelected = active
binding.readerVoiceButton.contentDescription = if (active) {
"Приостановить озвучивание"
} else {
"Слушать книгу. Удерживайте для настройки голоса, темпа и пауз"
}
}
private fun loadBook(bookId: Long) {
lifecycleScope.launch {
val data = withContext(Dispatchers.IO) {
@@ -1144,7 +1107,6 @@ class ReaderActivity : AppCompatActivity() {
binding.readerBackButton.imageTintList = ColorStateList.valueOf(palette.backIcon)
listOf(
binding.readerVoiceButton,
binding.readerSearchButton,
binding.readerSettingsButton,
binding.readerContentsButton,
@@ -111,10 +111,11 @@ class ReaderWebController(
override fun onRenderProcessGone(view: WebView?, detail: RenderProcessGoneDetail?): Boolean {
renderProcessGone = true
val rendererCrashed = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && detail?.didCrash() == true
onEvent(
ReaderEvent.Error(
stage = "renderer",
code = if (detail?.didCrash() == true) "RenderProcessCrash" else "RenderProcessGone",
code = if (rendererCrashed) "RenderProcessCrash" else "RenderProcessGone",
message = "Процесс отображения книги был перезапущен",
recoverable = true
)
@@ -212,24 +213,6 @@ class ReaderWebController(
}
}
suspend fun getSpeechPage(maxCharacters: Int): ReaderSpeechPage? =
withContext(Dispatchers.Main.immediate) {
withTimeoutOrNull(JS_TIMEOUT_MS) {
suspendCancellableCoroutine { continuation ->
val limit = maxCharacters.coerceIn(200, MAX_SPEECH_PAGE_CHARACTERS)
webView.evaluateJavascript(
"window.ReaderV2 && window.ReaderV2.getSpeechPageJson ? " +
"window.ReaderV2.getSpeechPageJson($limit) : null"
) { value ->
val result = decodeJavascriptString(value)
?.let { runCatching { JSONObject(it) }.getOrNull() }
?.let(ReaderSpeechPage::fromJson)
if (continuation.isActive) continuation.resume(result)
}
}
}
}
fun reload(): Boolean {
if (renderProcessGone) return false
shellReady = false
@@ -345,7 +328,6 @@ class ReaderWebController(
private const val READER_URL = "https://$APP_ASSET_HOST/assets/reader_v2/index.html"
private const val BOOK_URL = "https://$APP_ASSET_HOST/book/$BOOK_RESOURCE_NAME"
private const val JS_TIMEOUT_MS = 5_000L
private const val MAX_SPEECH_PAGE_CHARACTERS = 3_500
private fun emptyResponse(status: Int, reason: String): WebResourceResponse =
WebResourceResponse(
@@ -397,32 +379,3 @@ class ReaderWebController(
private val FB2_LEGACY_LOCATOR = Regex("^fb2:[^:]+:\\d+(?::\\d+)?$")
}
}
data class ReaderSpeechPage(
val text: String,
val locator: String?,
val canAdvance: Boolean,
val source: String,
val pageCharacters: Int,
val extendedCharacters: Int
) {
companion object {
internal fun fromJson(json: JSONObject): ReaderSpeechPage? {
val text = json.optString("text").trim()
if (text.isBlank()) return null
val locator = when (val value = json.opt("locator")) {
is JSONObject -> value.toString()
is String -> value.takeIf(String::isNotBlank)
else -> null
}
return ReaderSpeechPage(
text = text,
locator = locator,
canAdvance = json.optBoolean("canAdvance", true),
source = json.optString("source", "unknown"),
pageCharacters = json.optInt("pageCharacters", text.length),
extendedCharacters = json.optInt("extendedCharacters", 0)
)
}
}
}
@@ -1,231 +0,0 @@
package com.aletheia.app.ui.reader.tts
import android.content.Context
import android.util.Log
import com.aletheia.app.ui.reader.ReaderSpeechPage
import com.aletheia.app.ui.reader.ReaderWebController
import java.util.concurrent.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class ReaderSpeechController(
context: Context,
private val scope: CoroutineScope,
private val reader: ReaderWebController,
private val onStateChanged: (State) -> Unit,
private val onMessage: (String) -> Unit
) {
enum class State { IDLE, INITIALIZING, PLAYING, PAUSED, ERROR }
private val appContext = context.applicationContext
private val preferences = appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
private val engine = VoskSpeechEngine(appContext)
private var state = State.IDLE
private var initializationComplete = false
private var resumeAfterInitialization = false
private var initializationJob: Job? = null
private var playbackJob: Job? = null
private var currentPage: ReaderSpeechPage? = null
private var lastSpokenSignature: String? = null
val rate: Float
get() = preferences.getFloat(KEY_RATE, DEFAULT_RATE).coerceIn(MIN_RATE, MAX_RATE)
val pitch: Float
get() = preferences.getFloat(KEY_PITCH, DEFAULT_PITCH).coerceIn(MIN_PITCH, MAX_PITCH)
val pauseScale: Float
get() = preferences.getFloat(KEY_PAUSE_SCALE, DEFAULT_PAUSE_SCALE).coerceIn(MIN_PAUSE_SCALE, MAX_PAUSE_SCALE)
val articulationScale: Float
get() = preferences.getFloat(KEY_ARTICULATION_SCALE, DEFAULT_ARTICULATION_SCALE)
.coerceIn(MIN_ARTICULATION_SCALE, MAX_ARTICULATION_SCALE)
val speakerId: Int
get() = preferences.getInt(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID).coerceIn(0, VOICES.lastIndex)
fun toggle() {
when (state) {
State.PLAYING, State.INITIALIZING -> pause()
State.IDLE, State.PAUSED, State.ERROR -> start()
}
}
fun start() {
if (!initializationComplete) {
resumeAfterInitialization = true
initialize()
return
}
currentPage = null
lastSpokenSignature = null
updateState(State.PLAYING)
speakCurrentPage(reusePausedPage = false)
}
fun pause() {
resumeAfterInitialization = false
playbackJob?.cancel()
playbackJob = null
engine.stop()
updateState(State.PAUSED)
}
fun stop() {
resumeAfterInitialization = false
playbackJob?.cancel()
playbackJob = null
engine.stop()
currentPage = null
lastSpokenSignature = null
updateState(State.IDLE)
}
fun updateSettings(
rate: Float,
pitch: Float,
pauseScale: Float,
articulationScale: Float,
speakerId: Int
) {
preferences.edit()
.putFloat(KEY_RATE, rate.coerceIn(MIN_RATE, MAX_RATE))
.putFloat(KEY_PITCH, pitch.coerceIn(MIN_PITCH, MAX_PITCH))
.putFloat(KEY_PAUSE_SCALE, pauseScale.coerceIn(MIN_PAUSE_SCALE, MAX_PAUSE_SCALE))
.putFloat(
KEY_ARTICULATION_SCALE,
articulationScale.coerceIn(MIN_ARTICULATION_SCALE, MAX_ARTICULATION_SCALE)
)
.putInt(KEY_SPEAKER_ID, speakerId.coerceIn(0, VOICES.lastIndex))
.apply()
if (state == State.PLAYING) {
engine.stop()
speakCurrentPage(reusePausedPage = true)
}
}
fun destroy() {
initializationJob?.cancel()
playbackJob?.cancel()
initializationJob = null
playbackJob = null
engine.destroy()
initializationComplete = false
}
private fun initialize() {
if (initializationJob?.isActive == true) return
updateState(State.INITIALIZING)
initializationJob = scope.launch {
try {
engine.initialize()
initializationComplete = true
if (resumeAfterInitialization) {
resumeAfterInitialization = false
updateState(State.PLAYING)
speakCurrentPage(reusePausedPage = false)
} else {
updateState(State.IDLE)
}
} catch (error: Throwable) {
Log.e(TAG, "Offline voice initialization failed", error)
fail("Не удалось запустить встроенный офлайн-голос Vosk: ${error.message ?: error.javaClass.simpleName}")
}
}
}
private fun speakCurrentPage(reusePausedPage: Boolean) {
playbackJob?.cancel()
playbackJob = scope.launch {
try {
val page = if (reusePausedPage) currentPage else null
?: reader.getSpeechPage(MAX_PAGE_CHARS)
if (state != State.PLAYING) return@launch
if (page == null || page.text.isBlank()) {
fail("На текущей странице нет текста для озвучивания")
return@launch
}
Log.d(
TAG,
"Speech page extracted: chars=${page.text.length}, pageChars=${page.pageCharacters}, " +
"extended=${page.extendedCharacters}, canAdvance=${page.canAdvance}, source=${page.source}"
)
currentPage = page
val signature = page.signature()
if (!reusePausedPage && signature == lastSpokenSignature) {
stop()
onMessage("Достигнут конец книги")
return@launch
}
lastSpokenSignature = signature
engine.speak(page.text, rate, pitch, pauseScale, articulationScale)
advanceAfterPage()
} catch (_: CancellationException) {
// Pause and stop cancel the active synthesis/playback job.
} catch (error: Throwable) {
if (state == State.PLAYING) {
Log.e(TAG, "Offline speech playback failed", error)
fail("Ошибка встроенного синтеза речи: ${error.message ?: error.javaClass.simpleName}")
}
}
}
}
private suspend fun advanceAfterPage() {
if (state != State.PLAYING) return
val page = currentPage ?: return
if (!page.canAdvance) {
stop()
onMessage("Книга прочитана до конца")
return
}
currentPage = null
reader.next()
delay(PAGE_TURN_DELAY_MS)
if (state == State.PLAYING) speakCurrentPage(reusePausedPage = false)
}
private fun fail(message: String) {
playbackJob?.cancel()
playbackJob = null
engine.stop()
updateState(State.ERROR)
onMessage(message)
}
private fun updateState(next: State) {
Log.d(TAG, "State: $state -> $next")
state = next
onStateChanged(next)
}
private fun ReaderSpeechPage.signature(): String = "$locator|${text.take(160)}"
companion object {
private const val TAG = "AletheiaVoskTts"
const val MIN_RATE = 0.55f
const val MAX_RATE = 1.80f
const val MIN_PITCH = 0.75f
const val MAX_PITCH = 1.30f
const val MIN_PAUSE_SCALE = 0.50f
const val MAX_PAUSE_SCALE = 2.00f
const val MIN_ARTICULATION_SCALE = 0.90f
const val MAX_ARTICULATION_SCALE = 1.20f
val VOICES = listOf("Мужской 1")
private const val DEFAULT_RATE = 1.0f
private const val DEFAULT_PITCH = 1.0f
private const val DEFAULT_PAUSE_SCALE = 1.0f
private const val DEFAULT_ARTICULATION_SCALE = 1.06f
private const val DEFAULT_SPEAKER_ID = 2
private const val PREFERENCES_NAME = "reader_speech"
private const val KEY_RATE = "rate"
private const val KEY_PITCH = "pitch"
private const val KEY_PAUSE_SCALE = "pause_scale"
private const val KEY_ARTICULATION_SCALE = "articulation_scale"
private const val KEY_SPEAKER_ID = "speaker_id"
private const val MAX_PAGE_CHARS = 3_500
private const val PAGE_TURN_DELAY_MS = 450L
}
}
@@ -1,149 +0,0 @@
package com.aletheia.app.ui.reader.tts
import android.content.Context
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.SeekBar
import android.widget.Spinner
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.util.Locale
import kotlin.math.roundToInt
object ReaderSpeechSettingsDialog {
fun show(context: Context, controller: ReaderSpeechController) {
val density = context.resources.displayMetrics.density
val content = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
val horizontal = (24 * density).roundToInt()
val vertical = (8 * density).roundToInt()
setPadding(horizontal, vertical, horizontal, vertical)
}
val voiceTitle = TextView(context).apply {
text = "Голос Vosk TTS 0.9"
textSize = 15f
setPadding(0, 16, 0, 4)
}
val voice = Spinner(context).apply {
adapter = ArrayAdapter(
context,
android.R.layout.simple_spinner_dropdown_item,
ReaderSpeechController.VOICES
)
setSelection(controller.speakerId)
}
content.addView(voiceTitle)
content.addView(voice)
val rateValue = TextView(context)
val rate = slider(
context = context,
label = "Темп чтения",
value = controller.rate,
min = ReaderSpeechController.MIN_RATE,
max = ReaderSpeechController.MAX_RATE,
valueView = rateValue,
parent = content
)
val pitchValue = TextView(context)
val pitch = slider(
context = context,
label = "Высота голоса",
value = controller.pitch,
min = ReaderSpeechController.MIN_PITCH,
max = ReaderSpeechController.MAX_PITCH,
valueView = pitchValue,
parent = content
)
val pauseValue = TextView(context)
val pause = slider(
context = context,
label = "Паузы на знаках препинания",
value = controller.pauseScale,
min = ReaderSpeechController.MIN_PAUSE_SCALE,
max = ReaderSpeechController.MAX_PAUSE_SCALE,
valueView = pauseValue,
parent = content
)
val articulationValue = TextView(context)
val articulation = slider(
context = context,
label = "Разборчивость слов и букв",
value = controller.articulationScale,
min = ReaderSpeechController.MIN_ARTICULATION_SCALE,
max = ReaderSpeechController.MAX_ARTICULATION_SCALE,
valueView = articulationValue,
parent = content
)
MaterialAlertDialogBuilder(context)
.setTitle("Озвучивание книги")
.setMessage("Все параметры применяются к встроенному офлайн-голосу. Настройки меняются плавно.")
.setView(content)
.setNegativeButton("Отмена", null)
.setPositiveButton("Сохранить") { _, _ ->
controller.updateSettings(
progressToValue(rate.progress, ReaderSpeechController.MIN_RATE, ReaderSpeechController.MAX_RATE),
progressToValue(pitch.progress, ReaderSpeechController.MIN_PITCH, ReaderSpeechController.MAX_PITCH),
progressToValue(
pause.progress,
ReaderSpeechController.MIN_PAUSE_SCALE,
ReaderSpeechController.MAX_PAUSE_SCALE
),
progressToValue(
articulation.progress,
ReaderSpeechController.MIN_ARTICULATION_SCALE,
ReaderSpeechController.MAX_ARTICULATION_SCALE
),
voice.selectedItemPosition
)
}
.show()
}
private fun slider(
context: Context,
label: String,
value: Float,
min: Float,
max: Float,
valueView: TextView,
parent: LinearLayout
): SeekBar {
val title = TextView(context).apply {
text = label
textSize = 15f
setPadding(0, 16, 0, 0)
}
val seekBar = SeekBar(context).apply {
this.max = SLIDER_STEPS
progress = valueToProgress(value, min, max)
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
fun render(progress: Int) {
valueView.text = String.format(
Locale.getDefault(),
"%.2f×",
progressToValue(progress, min, max)
)
}
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) = render(progress)
override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit
override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit
})
render(seekBar.progress)
parent.addView(title)
parent.addView(valueView)
parent.addView(seekBar)
return seekBar
}
private fun valueToProgress(value: Float, min: Float, max: Float): Int =
(((value.coerceIn(min, max) - min) / (max - min)) * SLIDER_STEPS).roundToInt()
private fun progressToValue(progress: Int, min: Float, max: Float): Float =
min + (max - min) * progress.coerceIn(0, SLIDER_STEPS) / SLIDER_STEPS
private const val SLIDER_STEPS = 300
}
@@ -1,141 +0,0 @@
package com.aletheia.app.ui.reader.tts
internal object RussianNumberNormalizer {
private val yearInPrepositionalPattern = Regex(
"(?<![\\p{L}\\p{N}])(\\d{4})\\s+(году|г\\.)",
RegexOption.IGNORE_CASE
)
private val numberPattern = Regex(
"(?<![\\p{L}\\p{N}])([+-]?)(\\d{1,18})(?:[,.](\\d+))?(?![\\p{L}\\p{N}])"
)
fun normalize(text: String): String {
val prepared = text
.replace(Regex("(?<=\\d)[\\u00A0\\u202F](?=\\d{3}(?:\\D|$))"), "")
.replace(Regex("\\s*(?=\\d)"), "номер ")
val yearsNormalized = yearInPrepositionalPattern.replace(prepared) { match ->
val year = match.groupValues[1].toInt()
"${yearInPrepositionalWords(year)} ${match.groupValues[2]}"
}
return numberPattern.replace(yearsNormalized) { match ->
val sign = when (match.groupValues[1]) {
"-" -> "минус "
"+" -> "плюс "
else -> ""
}
val integerDigits = match.groupValues[2]
val integer = if (integerDigits.length > 1 && integerDigits.startsWith('0')) {
integerDigits.toDigitWords()
} else {
integerDigits.toLongOrNull()?.let(::integerToWords) ?: integerDigits.toDigitWords()
}
val fractionDigits = match.groupValues[3]
if (fractionDigits.isBlank()) "$sign$integer" else "$sign$integer запятая ${fractionDigits.toDigitWords()}"
}
}
private fun yearInPrepositionalWords(value: Int): String {
require(value in 1000..9999)
if (value % 1000 == 0) return EXACT_THOUSANDTH_PREPOSITIONAL[value / 1000]
val thousands = value / 1000
val thousandsCardinal = underThousandToWords(thousands, feminine = true)
val thousandsWords = if (thousandsCardinal == "одна") {
SCALES[1]!!.formFor(thousands)
} else {
"$thousandsCardinal ${SCALES[1]!!.formFor(thousands)}"
}
return "$thousandsWords ${ordinalUnderThousandPrepositional(value % 1000)}"
}
private fun ordinalUnderThousandPrepositional(value: Int): String {
require(value in 1..999)
val lastTwo = value % 100
val units = value % 10
return when {
units != 0 && lastTwo !in 11..19 ->
"${integerToWords((value - units).toLong())} ${ORDINAL_UNITS_PREPOSITIONAL[units]}".trim()
lastTwo in 11..19 ->
"${integerToWords((value - lastTwo).toLong())} ${ORDINAL_TEENS_PREPOSITIONAL[lastTwo - 10]}".trim()
lastTwo >= 20 ->
"${integerToWords((value - lastTwo).toLong())} ${ORDINAL_TENS_PREPOSITIONAL[lastTwo / 10]}".trim()
else -> ORDINAL_HUNDREDS_PREPOSITIONAL[value / 100]
}
}
private fun integerToWords(value: Long): String {
if (value == 0L) return DIGITS[0]
if (value < 0L) return "минус ${integerToWords(-value)}"
val groups = mutableListOf<Int>()
var remainder = value
while (remainder > 0) {
groups += (remainder % 1_000).toInt()
remainder /= 1_000
}
return groups.indices.reversed().mapNotNull { groupIndex ->
val group = groups[groupIndex]
if (group == 0) return@mapNotNull null
val feminine = groupIndex == 1
val words = underThousandToWords(group, feminine)
val scale = SCALES.getOrNull(groupIndex)
if (scale == null) words else "$words ${scale.formFor(group)}"
}.joinToString(" ")
}
private fun underThousandToWords(value: Int, feminine: Boolean): String {
val words = mutableListOf<String>()
val hundreds = value / 100
if (hundreds > 0) words += HUNDREDS[hundreds]
val tail = value % 100
when {
tail in 10..19 -> words += TEENS[tail - 10]
else -> {
val tens = tail / 10
if (tens > 0) words += TENS[tens]
val units = tail % 10
if (units > 0) {
words += when {
feminine && units == 1 -> "одна"
feminine && units == 2 -> "две"
else -> DIGITS[units]
}
}
}
}
return words.joinToString(" ")
}
private fun String.toDigitWords(): String = map { DIGITS[it.digitToInt()] }.joinToString(" ")
private data class Scale(val one: String, val few: String, val many: String) {
fun formFor(value: Int): String {
val lastTwo = value % 100
if (lastTwo in 11..14) return many
return when (value % 10) {
1 -> one
2, 3, 4 -> few
else -> many
}
}
}
private val DIGITS = listOf("ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять")
private val TEENS = listOf(
"десять", "одиннадцать", "двенадцать", "тринадцать", "четырнадцать",
"пятнадцать", "шестнадцать", "семнадцать", "восемнадцать", "девятнадцать"
)
private val TENS = listOf("", "десять", "двадцать", "тридцать", "сорок", "пятьдесят", "шестьдесят", "семьдесят", "восемьдесят", "девяносто")
private val HUNDREDS = listOf("", "сто", "двести", "триста", "четыреста", "пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот")
private val ORDINAL_UNITS_PREPOSITIONAL = listOf("", "первом", "втором", "третьем", "четвёртом", "пятом", "шестом", "седьмом", "восьмом", "девятом")
private val ORDINAL_TEENS_PREPOSITIONAL = listOf("десятом", "одиннадцатом", "двенадцатом", "тринадцатом", "четырнадцатом", "пятнадцатом", "шестнадцатом", "семнадцатом", "восемнадцатом", "девятнадцатом")
private val ORDINAL_TENS_PREPOSITIONAL = listOf("", "десятом", "двадцатом", "тридцатом", "сороковом", "пятидесятом", "шестидесятом", "семидесятом", "восьмидесятом", "девяностом")
private val ORDINAL_HUNDREDS_PREPOSITIONAL = listOf("", "сотом", "двухсотом", "трёхсотом", "четырёхсотом", "пятисотом", "шестисотом", "семисотом", "восьмисотом", "девятисотом")
private val EXACT_THOUSANDTH_PREPOSITIONAL = listOf("", "тысячном", "двухтысячном", "трёхтысячном", "четырёхтысячном", "пятитысячном", "шеститысячном", "семитысячном", "восьмитысячном", "девятитысячном")
private val SCALES = listOf(
null,
Scale("тысяча", "тысячи", "тысяч"),
Scale("миллион", "миллиона", "миллионов"),
Scale("миллиард", "миллиарда", "миллиардов"),
Scale("триллион", "триллиона", "триллионов"),
Scale("квадриллион", "квадриллиона", "квадриллионов")
)
}
@@ -1,62 +0,0 @@
package com.aletheia.app.ui.reader.tts
import java.util.Locale
internal object RussianSpeechTextSplitter {
fun splitSentences(text: String): List<String> {
val result = mutableListOf<String>()
var start = 0
var index = 0
while (index < text.length) {
val char = text[index]
val isEllipsis = char == '…' || text.startsWith("...", index)
val isTerminal = when {
char == '!' || char == '?' || isEllipsis -> true
char == '.' -> isSentencePeriod(text, index)
else -> false
}
if (!isTerminal) {
index += 1
continue
}
if (text.startsWith("...", index)) index += 2
var end = index + 1
while (end < text.length && text[end] in SENTENCE_CLOSERS) end += 1
val hasSentenceBoundary = end == text.length || text[end].isWhitespace()
if (hasSentenceBoundary && !isFollowedByDialogueDash(text, end)) {
val sentence = text.substring(start, end).trim()
if (sentence.isNotBlank()) result += sentence
start = end
while (start < text.length && text[start].isWhitespace()) start += 1
index = start
} else {
index = end
}
}
val remainder = text.substring(start).trim()
if (remainder.isNotBlank()) result += remainder
return result
}
private fun isFollowedByDialogueDash(text: String, end: Int): Boolean {
var index = end
while (index < text.length && text[index].isWhitespace()) index += 1
return text.getOrNull(index) in DIALOGUE_DASHES
}
private fun isSentencePeriod(text: String, index: Int): Boolean {
if (text.getOrNull(index - 1)?.isDigit() == true && text.getOrNull(index + 1)?.isDigit() == true) return false
val prefix = text.substring(0, index)
val token = prefix.takeLastWhile { it.isLetter() }.lowercase(Locale.forLanguageTag("ru"))
if (token in NON_TERMINAL_ABBREVIATIONS) return false
if (token.length == 1 && prefix.lastOrNull()?.isUpperCase() == true) return false
return true
}
private val SENTENCE_CLOSERS = setOf('"', '\'', '»', '”', ')')
private val DIALOGUE_DASHES = setOf('—', '', '-')
private val NON_TERMINAL_ABBREVIATIONS = setOf(
"г", "гг", "ул", "стр", "рис", "им", "т", "д", "п", "н", "э", "е", "к",
"др", "см", "руб", "коп", "тыс", "млн", "млрд"
)
}
@@ -1,708 +0,0 @@
package com.aletheia.app.ui.reader.tts
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.media.PlaybackParams
import android.util.Log
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.nio.FloatBuffer
import java.nio.LongBuffer
import java.nio.charset.StandardCharsets
import java.util.LinkedHashMap
import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import kotlin.coroutines.coroutineContext
class VoskSpeechEngine(context: Context) {
private val appContext = context.applicationContext
private val playbackGeneration = AtomicInteger()
private val trackLock = Any()
private val environment = OrtEnvironment.getEnvironment()
private var synthesisSession: OrtSession? = null
private var bertSession: OrtSession? = null
private var dictionary: PronunciationDictionary? = null
private var tokenizer: WordPieceTokenizer? = null
private var activeTrack: AudioTrack? = null
suspend fun initialize() = withContext(Dispatchers.IO) {
if (synthesisSession != null) return@withContext
val modelFile = copyAssetToInternalStorage(MODEL_ASSET, MODEL_FILE)
val bertFile = copyAssetToInternalStorage(BERT_ASSET, BERT_FILE)
val dictionaryFile = copyAssetToInternalStorage(DICTIONARY_ASSET, DICTIONARY_FILE)
val dictionaryIndex = appContext.assets.open(DICTIONARY_INDEX_ASSET).bufferedReader().useLines { lines ->
lines.mapNotNull { line ->
val separator = line.lastIndexOf('\t')
if (separator <= 0) null else DictionaryIndexEntry(
line.substring(0, separator),
line.substring(separator + 1).toLong()
)
}.toList()
}
val vocabulary = appContext.assets.open(VOCABULARY_ASSET).bufferedReader().useLines { lines ->
lines.mapIndexed { index, token -> token to index.toLong() }.toMap()
}
val options = OrtSession.SessionOptions().apply {
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.NO_OPT)
setIntraOpNumThreads(INFERENCE_THREADS)
setInterOpNumThreads(1)
}
synthesisSession = environment.createSession(modelFile.absolutePath, options)
bertSession = environment.createSession(bertFile.absolutePath, options)
dictionary = PronunciationDictionary(dictionaryFile, dictionaryIndex)
tokenizer = WordPieceTokenizer(vocabulary)
Log.i(
TAG,
"Vosk TTS 0.9 loaded: model=${modelFile.length()} bert=${bertFile.length()} " +
"dictionary=${dictionaryFile.length()}"
)
}
suspend fun speak(
text: String,
rate: Float,
pitch: Float,
pauseScale: Float,
articulationScale: Float
) = withContext(Dispatchers.Default) {
val localSynthesis = checkNotNull(synthesisSession) { "Vosk TTS is not initialized" }
val localBert = checkNotNull(bertSession) { "Vosk BERT is not initialized" }
val localDictionary = checkNotNull(dictionary) { "Vosk dictionary is not initialized" }
val localTokenizer = checkNotNull(tokenizer) { "Vosk tokenizer is not initialized" }
val generation = playbackGeneration.incrementAndGet()
for (chunk in splitText(text)) {
coroutineContext.ensureActive()
check(generation == playbackGeneration.get()) { "Playback stopped" }
val prepared = prepareInput(chunk.text, localDictionary, localTokenizer, localBert)
if (prepared.timeSteps <= 2) continue
Log.d(TAG, "Synthesizing ${chunk.text.length} chars as ${prepared.timeSteps} phonemes")
val output = synthesize(localSynthesis, prepared, articulationScale)
check(output.isNotEmpty()) { "Vosk returned empty audio" }
val playbackSpeed = (rate * BOOK_SPEED_SCALE).coerceIn(MIN_PLAYBACK_SPEED, MAX_PLAYBACK_SPEED)
val scaledPauseMs = if (chunk.trailingPauseMs == NO_TRAILING_PAUSE_MS) {
NO_TRAILING_PAUSE_MS
} else {
(chunk.trailingPauseMs * pauseScale / rate.coerceAtLeast(MIN_RATE_FOR_PAUSE_SCALING))
.toInt()
.coerceAtLeast(MIN_TRAILING_PAUSE_MS)
.coerceAtMost(chunk.maximumPauseMs)
}
Log.d(
TAG,
"Generated ${output.size} samples at $SAMPLE_RATE Hz for ${chunk.text.length} chars; " +
"speaker=$MALE_1_SPEAKER_ID speed=$playbackSpeed pitch=$pitch " +
"articulation=$articulationScale pause=${scaledPauseMs}ms"
)
play(output, generation, playbackSpeed, pitch, scaledPauseMs)
}
}
fun stop() {
playbackGeneration.incrementAndGet()
synchronized(trackLock) {
activeTrack?.runCatching { pause() }
activeTrack?.runCatching { flush() }
activeTrack?.runCatching { stop() }
activeTrack?.release()
activeTrack = null
}
}
fun destroy() {
stop()
dictionary?.close()
dictionary = null
tokenizer = null
bertSession?.close()
bertSession = null
synthesisSession?.close()
synthesisSession = null
}
private fun prepareInput(
text: String,
dictionary: PronunciationDictionary,
tokenizer: WordPieceTokenizer,
bert: OrtSession
): PreparedInput {
val normalized = RussianNumberNormalizer.normalize(text)
.replace(Regex("([А-Яа-яЁё])\\u0301"), "+$1")
.lowercase(Locale.forLanguageTag("ru"))
.replace('—', '-')
.replace('', '-')
.replace("", "...")
.replace(Regex("[«»“”]"), "\"")
.replace(Regex("\\s+"), " ")
.trim()
val encoding = tokenizer.encode(normalized)
val tokenEmbeddings = runBert(bert, encoding.ids)
val selectedEmbeddings = encoding.embeddingPositions.map { position -> tokenEmbeddings[position] }
val phones = buildMultistreamPhones(normalized, dictionary)
val timeSteps = phones.size
val features = LongArray(FEATURE_CHANNELS * timeSteps)
val embeddings = FloatArray(BERT_DIMENSIONS * timeSteps)
phones.forEachIndexed { time, phone ->
phone.features.forEachIndexed { channel, value -> features[channel * timeSteps + time] = value }
val embedding = selectedEmbeddings[phone.bertWordIndex.coerceIn(selectedEmbeddings.indices)]
for (channel in 0 until BERT_DIMENSIONS) {
embeddings[channel * timeSteps + time] = embedding[channel]
}
}
return PreparedInput(features, embeddings, timeSteps)
}
private fun runBert(session: OrtSession, ids: LongArray): Array<FloatArray> {
val shape = longArrayOf(1, ids.size.toLong())
val inputIds = OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape)
val attentionMask = OnnxTensor.createTensor(
environment,
LongBuffer.wrap(LongArray(ids.size) { 1L }),
shape
)
val tokenTypes = OnnxTensor.createTensor(environment, LongBuffer.wrap(LongArray(ids.size)), shape)
try {
session.run(
mapOf(
"input_ids" to inputIds,
"attention_mask" to attentionMask,
"token_type_ids" to tokenTypes
)
).use { result ->
@Suppress("UNCHECKED_CAST")
return result[0].value as Array<FloatArray>
}
} finally {
inputIds.close()
attentionMask.close()
tokenTypes.close()
}
}
private fun synthesize(session: OrtSession, prepared: PreparedInput, articulationScale: Float): FloatArray {
val input = OnnxTensor.createTensor(
environment,
LongBuffer.wrap(prepared.features),
longArrayOf(1, FEATURE_CHANNELS.toLong(), prepared.timeSteps.toLong())
)
val inputLengths = OnnxTensor.createTensor(
environment,
LongBuffer.wrap(longArrayOf(prepared.timeSteps.toLong())),
longArrayOf(1)
)
val scales = OnnxTensor.createTensor(
environment,
FloatBuffer.wrap(
floatArrayOf(
NOISE_SCALE,
articulationScale.coerceIn(MIN_ARTICULATION_SCALE, MAX_ARTICULATION_SCALE),
DURATION_NOISE_SCALE
)
),
longArrayOf(3)
)
val speaker = OnnxTensor.createTensor(
environment,
LongBuffer.wrap(longArrayOf(MALE_1_SPEAKER_ID.toLong())),
longArrayOf(1)
)
val bert = OnnxTensor.createTensor(
environment,
FloatBuffer.wrap(prepared.bertEmbeddings),
longArrayOf(1, BERT_DIMENSIONS.toLong(), prepared.timeSteps.toLong())
)
try {
session.run(
mapOf(
"input" to input,
"input_lengths" to inputLengths,
"scales" to scales,
"sid" to speaker,
"bert" to bert
)
).use { result ->
return flattenFloatOutput(result[0].value)
}
} finally {
input.close()
inputLengths.close()
scales.close()
speaker.close()
bert.close()
}
}
private fun buildMultistreamPhones(
text: String,
dictionary: PronunciationDictionary
): List<PhoneFeatures> {
val rawPhones = mutableListOf(RawPhone("^", emptyList(), 0, 0))
val word = StringBuilder()
val pendingPunctuation = mutableListOf<String>()
var inQuote = 0
var bertWordIndex = 1
fun flushWord() {
if (word.isEmpty()) return
val value = word.toString()
val pronunciation = dictionary.find(value) ?: RussianG2p.convert(value)
pronunciation.split(' ').filter(String::isNotBlank).forEach { phone ->
rawPhones += RawPhone(phone, emptyList(), inQuote, bertWordIndex)
}
word.clear()
bertWordIndex += 1
}
fun appendSpace() {
rawPhones += RawPhone(" ", pendingPunctuation.toList(), inQuote, bertWordIndex)
pendingPunctuation.clear()
}
var index = 0
while (index < text.length) {
if (text.startsWith("...", index)) {
flushWord()
pendingPunctuation += "..."
index += 3
continue
}
val char = text[index]
when {
char == '"' || char == '«' || char == '»' || char == '“' || char == '”' -> {
flushWord()
inQuote = 1 - inQuote
}
char.isWhitespace() -> {
flushWord()
appendSpace()
}
char == '-' && text.getOrNull(index - 1)?.isLetterOrDigit() == true &&
text.getOrNull(index + 1)?.isLetterOrDigit() == true -> {
// A lexical hyphen separates WordPiece words but must not create an acoustic pause.
flushWord()
}
char in MULTISTREAM_PUNCTUATION -> {
flushWord()
pendingPunctuation += char.toString()
}
char.isLetterOrDigit() || char == '+' -> word.append(char)
}
index += 1
}
flushWord()
appendSpace()
rawPhones += RawPhone("$", emptyList(), 0, bertWordIndex)
var lastPunctuation = " "
var lastSentencePunctuation = " "
val reversed = mutableListOf<PhoneFeatures>()
for (phone in rawPhones.asReversed()) {
lastSentencePunctuation = when {
"..." in phone.punctuation -> "..."
"." in phone.punctuation -> "."
"!" in phone.punctuation -> "!"
"?" in phone.punctuation -> "?"
"-" in phone.punctuation -> "-"
else -> lastSentencePunctuation
}
if (phone.punctuation.isNotEmpty()) lastPunctuation = phone.punctuation.first()
val currentPunctuation = phone.punctuation.firstOrNull() ?: "_"
val values = longArrayOf(
PHONEME_IDS.getValue(phone.phone),
PHONEME_IDS.getValue(currentPunctuation),
phone.inQuote.toLong(),
PHONEME_IDS.getValue(lastPunctuation),
PHONEME_IDS.getValue(lastSentencePunctuation)
)
reversed += PhoneFeatures(values, phone.bertWordIndex)
}
return reversed.asReversed()
}
private fun flattenFloatOutput(value: Any?): FloatArray = when (value) {
is FloatArray -> value
is Array<*> -> value.asSequence().flatMap { flattenFloatOutput(it).asSequence() }.toList().toFloatArray()
else -> error("Unexpected Vosk output type: ${value?.javaClass?.name}")
}
private fun play(
samples: FloatArray,
generation: Int,
playbackSpeed: Float,
pitch: Float,
trailingPauseMs: Int
) {
val minBytes = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_FLOAT
).coerceAtLeast(4096)
val track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
)
.setBufferSizeInBytes(minBytes)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
track.playbackParams = PlaybackParams()
.allowDefaults()
.setSpeed(playbackSpeed)
.setPitch(pitch.coerceIn(MIN_PLAYBACK_PITCH, MAX_PLAYBACK_PITCH))
synchronized(trackLock) { activeTrack = track }
try {
track.play()
var offset = 0
while (offset < samples.size && generation == playbackGeneration.get()) {
val written = track.write(samples, offset, samples.size - offset, AudioTrack.WRITE_BLOCKING)
if (written <= 0) error("AudioTrack write failed: $written")
offset += written
}
var silenceSamples = trailingPauseMs * SAMPLE_RATE / 1_000
while (silenceSamples > 0 && generation == playbackGeneration.get()) {
val count = minOf(silenceSamples, SILENCE_BUFFER.size)
val written = track.write(SILENCE_BUFFER, 0, count, AudioTrack.WRITE_BLOCKING)
if (written <= 0) error("AudioTrack silence write failed: $written")
silenceSamples -= written
}
check(generation == playbackGeneration.get()) { "Playback stopped" }
} finally {
synchronized(trackLock) { if (activeTrack === track) activeTrack = null }
track.runCatching { stop() }
track.release()
}
}
private fun splitText(text: String): List<SpeechChunk> {
val normalized = text
.replace(Regex("\\[\\d+(?:[,-]\\d+)*\\]"), " ")
.replace("\r\n", "\n")
.replace('\r', '\n')
.replace(Regex("[\\t\\x0B\\f ]+"), " ")
.replace(Regex(" *\\n+ *"), "\n")
.trim()
if (normalized.isBlank()) return emptyList()
val chunks = mutableListOf<SpeechChunk>()
for (paragraph in normalized.split('\n').filter(String::isNotBlank)) {
for (sentence in RussianSpeechTextSplitter.splitSentences(paragraph)) {
for (unit in splitControlledPauses(sentence)) {
val parts = splitAtWordBoundaries(unit.text)
parts.forEachIndexed { index, part ->
val isLastPart = index == parts.lastIndex
chunks += SpeechChunk(
text = part.trim(),
trailingPauseMs = if (isLastPart) unit.trailingPauseMs else NO_TRAILING_PAUSE_MS,
maximumPauseMs = if (isLastPart) unit.maximumPauseMs else NO_TRAILING_PAUSE_MS
)
}
}
}
}
return chunks.filter { it.text.isNotBlank() }
}
private fun splitControlledPauses(text: String): List<ProsodicUnit> {
val units = mutableListOf<ProsodicUnit>()
val current = StringBuilder()
fun flushControlledPause() {
val value = current.toString().trim()
if (value.isNotBlank()) {
units += ProsodicUnit(value, CONTROLLED_PUNCTUATION_PAUSE_MS, CONTROLLED_PUNCTUATION_MAX_PAUSE_MS)
}
current.clear()
}
text.forEachIndexed { index, char ->
val isWordInternalHyphen = char == '-' &&
text.getOrNull(index - 1)?.isLetterOrDigit() == true &&
text.getOrNull(index + 1)?.isLetterOrDigit() == true
when {
char in CONTROLLED_PAUSE_PUNCTUATION && !isWordInternalHyphen -> flushControlledPause()
else -> current.append(char)
}
}
val remainder = current.toString().trim()
if (remainder.isNotBlank()) units += ProsodicUnit(remainder, pauseAfter(remainder), Int.MAX_VALUE)
return units
}
private fun splitAtWordBoundaries(text: String): List<String> {
if (text.length <= MAX_CHUNK_CHARS) return listOf(text)
val parts = mutableListOf<String>()
var remaining = text.trim()
while (remaining.length > MAX_CHUNK_CHARS) {
val boundary = remaining.lastIndexOf(' ', startIndex = MAX_CHUNK_CHARS)
.takeIf { it > MIN_CHUNK_CHARS }
?: remaining.indexOf(' ', startIndex = MAX_CHUNK_CHARS).takeIf { it >= 0 }
?: remaining.length
parts += remaining.substring(0, boundary).trim()
remaining = remaining.substring(boundary).trimStart()
}
if (remaining.isNotBlank()) parts += remaining
return parts
}
private fun pauseAfter(text: String): Int = when (text.trimEnd().lastOrNull()) {
'…' -> ELLIPSIS_PAUSE_MS
'?' -> QUESTION_PAUSE_MS
'!' -> EXCLAMATION_PAUSE_MS
'.' -> SENTENCE_PAUSE_MS
':', ';' -> CLAUSE_PAUSE_MS
else -> NO_TRAILING_PAUSE_MS
}
private fun copyAssetToInternalStorage(assetPath: String, fileName: String): File {
val destination = File(appContext.filesDir, "tts/vosk_0_9/$fileName")
val expectedSize = appContext.assets.open(assetPath).use { input ->
var total = 0L
val buffer = ByteArray(COPY_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
total += read
}
total
}
if (destination.isFile && destination.length() == expectedSize) return destination
destination.parentFile?.mkdirs()
val temporary = File(destination.parentFile, "$fileName.tmp")
appContext.assets.open(assetPath).use { input ->
FileOutputStream(temporary).use { output -> input.copyTo(output, COPY_BUFFER_SIZE) }
}
check(temporary.length() == expectedSize) { "Vosk asset copy is incomplete: $fileName" }
if (destination.exists()) check(destination.delete()) { "Cannot replace old Vosk asset: $fileName" }
check(temporary.renameTo(destination)) { "Cannot install Vosk asset: $fileName" }
return destination
}
private class WordPieceTokenizer(private val vocabulary: Map<String, Long>) {
fun encode(text: String): BertEncoding {
val ids = mutableListOf(vocabulary.getValue("[CLS]"))
val embeddingPositions = mutableListOf(0)
for (token in basicTokens(text)) {
val punctuation = token in BERT_EXCLUDED_PUNCTUATION
val pieces = if (punctuation) {
listOf(vocabulary[token] ?: vocabulary.getValue("[UNK]"))
} else {
wordPieces(token)
}
if (!punctuation) embeddingPositions += ids.size
ids += pieces
}
ids += vocabulary.getValue("[SEP]")
embeddingPositions += ids.lastIndex
return BertEncoding(ids.toLongArray(), embeddingPositions.toIntArray())
}
private fun basicTokens(text: String): List<String> {
val result = mutableListOf<String>()
val word = StringBuilder()
fun flush() {
if (word.isNotEmpty()) result += word.toString().also { word.clear() }
}
text.forEach { char ->
when {
char.isWhitespace() -> flush()
char.isLetterOrDigit() -> word.append(char.lowercaseChar())
char == '+' -> Unit // Explicit stress marker belongs to G2P, not to BERT tokenization.
else -> {
flush()
result += char.toString()
}
}
}
flush()
return result
}
private fun wordPieces(token: String): List<Long> {
if (token.length > MAX_WORD_PIECE_CHARS) return listOf(vocabulary.getValue("[UNK]"))
val result = mutableListOf<Long>()
var start = 0
while (start < token.length) {
var end = token.length
var found: Long? = null
while (start < end) {
val piece = token.substring(start, end).let { if (start == 0) it else "##$it" }
found = vocabulary[piece]
if (found != null) break
end -= 1
}
if (found == null) return listOf(vocabulary.getValue("[UNK]"))
result += found
start = end
}
return result
}
}
private data class DictionaryIndexEntry(val word: String, val offset: Long)
private class PronunciationDictionary(
file: File,
private val index: List<DictionaryIndexEntry>
) : AutoCloseable {
private val source = RandomAccessFile(file, "r")
private val cache = object : LinkedHashMap<String, String>(CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean = size > CACHE_SIZE
}
@Synchronized
fun find(word: String): String? {
cache[word]?.let { return it }
var low = 0
var high = index.lastIndex
while (low <= high) {
val midpoint = (low + high) ushr 1
if (index[midpoint].word <= word) low = midpoint + 1 else high = midpoint - 1
}
val rangeIndex = high.coerceAtLeast(0)
val rangeEnd = index.getOrNull(rangeIndex + 1)?.offset ?: source.length()
source.seek(index[rangeIndex].offset)
while (source.filePointer < rangeEnd) {
val line = readUtf8Line(source) ?: break
val entryWord = line.substringBefore('\t')
when {
entryWord < word -> continue
entryWord > word -> return null
else -> {
val pronunciation = line.substringAfter('\t', "").trim()
if (pronunciation.isNotEmpty()) cache[word] = pronunciation
return pronunciation.takeIf(String::isNotEmpty)
}
}
}
return null
}
override fun close() = source.close()
private fun readUtf8Line(file: RandomAccessFile): String? {
val bytes = ByteArrayOutputStream(64)
while (true) {
val value = file.read()
if (value < 0) return if (bytes.size() == 0) null else bytes.toString(StandardCharsets.UTF_8.name())
if (value == '\n'.code) break
if (value != '\r'.code) bytes.write(value)
}
return bytes.toString(StandardCharsets.UTF_8.name())
}
}
private object RussianG2p {
private val softLetters = setOf('я', 'ё', 'ю', 'и', 'ь', 'е')
private val syllableStarts = setOf('#', 'ъ', 'ь', 'а', 'я', 'о', 'ё', 'у', 'ю', 'э', 'е', 'и', 'ы', '-')
private val softHardConsonants = mapOf(
'б' to "b", 'в' to "v", 'г' to "g", 'д' to "d", 'з' to "z", 'к' to "k",
'л' to "l", 'м' to "m", 'н' to "n", 'п' to "p", 'р' to "r", 'с' to "s",
'т' to "t", 'ф' to "f", 'х' to "h"
)
private val otherConsonants = mapOf('ж' to "zh", 'ц' to "c", 'ч' to "ch", 'ш' to "sh", 'щ' to "sch", 'й' to "j")
private val vowels = mapOf('а' to "a", 'я' to "a", 'у' to "u", 'ю' to "u", 'о' to "o", 'ё' to "o", 'э' to "e", 'е' to "e", 'и' to "i", 'ы' to "y")
fun convert(word: String): String {
val marked = mutableListOf<Pair<Char, Int>>()
var stress = 0
for (char in "#$word#") {
if (char == '+') stress = 1 else {
marked += char to stress
stress = 0
}
}
val phones = mutableListOf<String>()
for (index in marked.indices) {
val (char, accent) = marked[index]
val next = marked.getOrNull(index + 1)?.first
when {
char in softHardConsonants -> phones += softHardConsonants.getValue(char) + if (next in softLetters) "j" else ""
char in otherConsonants -> phones += otherConsonants.getValue(char)
char in vowels -> {
val previous = marked.getOrNull(index - 1)?.first ?: '#'
if (previous in syllableStarts && char in setOf('я', 'ю', 'е', 'ё')) phones += "j"
phones += vowels.getValue(char) + accent
}
}
}
return phones.joinToString(" ")
}
}
private companion object {
data class PreparedInput(
val features: LongArray,
val bertEmbeddings: FloatArray,
val timeSteps: Int
)
data class BertEncoding(val ids: LongArray, val embeddingPositions: IntArray)
data class RawPhone(val phone: String, val punctuation: List<String>, val inQuote: Int, val bertWordIndex: Int)
data class PhoneFeatures(val features: LongArray, val bertWordIndex: Int)
data class SpeechChunk(val text: String, val trailingPauseMs: Int, val maximumPauseMs: Int = Int.MAX_VALUE)
data class ProsodicUnit(val text: String, val trailingPauseMs: Int, val maximumPauseMs: Int)
const val TAG = "AletheiaVoskTts"
const val MODEL_FILE = "model.onnx"
const val BERT_FILE = "bert.int8.onnx"
const val DICTIONARY_FILE = "dict.tsv"
const val MODEL_ASSET = "tts/vosk_0_9/$MODEL_FILE"
const val BERT_ASSET = "tts/vosk_0_9/$BERT_FILE"
const val DICTIONARY_ASSET = "tts/vosk_0_9/$DICTIONARY_FILE"
const val DICTIONARY_INDEX_ASSET = "tts/vosk_0_9/dictionary.index"
const val VOCABULARY_ASSET = "tts/vosk_0_9/vocab.txt"
const val SAMPLE_RATE = 22_050
const val MALE_1_SPEAKER_ID = 4
const val FEATURE_CHANNELS = 5
const val BERT_DIMENSIONS = 768
const val INFERENCE_THREADS = 4
const val COPY_BUFFER_SIZE = 1024 * 1024
const val CACHE_SIZE = 4_096
const val MAX_WORD_PIECE_CHARS = 100
const val MAX_CHUNK_CHARS = 120
const val MIN_CHUNK_CHARS = 80
const val BOOK_SPEED_SCALE = 0.82f
const val MIN_RATE_FOR_PAUSE_SCALING = 0.10f
const val MIN_PLAYBACK_SPEED = 0.50f
const val MAX_PLAYBACK_SPEED = 1.50f
const val MIN_PLAYBACK_PITCH = 0.75f
const val MAX_PLAYBACK_PITCH = 1.30f
const val MIN_ARTICULATION_SCALE = 0.90f
const val MAX_ARTICULATION_SCALE = 1.20f
const val NOISE_SCALE = 0.8f
const val DURATION_NOISE_SCALE = 0.8f
const val NO_TRAILING_PAUSE_MS = 0
const val MIN_TRAILING_PAUSE_MS = 80
const val CLAUSE_PAUSE_MS = 320
const val CONTROLLED_PUNCTUATION_PAUSE_MS = 300
const val CONTROLLED_PUNCTUATION_MAX_PAUSE_MS = 300
const val SENTENCE_PAUSE_MS = 600
const val EXCLAMATION_PAUSE_MS = 600
const val QUESTION_PAUSE_MS = 600
const val ELLIPSIS_PAUSE_MS = 1_200
val SILENCE_BUFFER = FloatArray(SAMPLE_RATE / 10)
val CONTROLLED_PAUSE_PUNCTUATION = setOf('—', '', '-', '(', ')', ';', ':')
val MULTISTREAM_PUNCTUATION = setOf('!', '(', ')', ',', '-', '.', ':', ';', '?')
val BERT_EXCLUDED_PUNCTUATION = setOf("-", ",", ".", "?", "!", ";", ":", "\"")
val PHONEME_IDS = listOf(
"_", "^", "$", " ", "!", "'", "(", ")", ",", "-", ".", "...", ":", ";", "?",
"a0", "a1", "b", "bj", "c", "ch", "d", "dj", "e0", "e1", "f", "fj", "g", "gj",
"h", "hj", "i0", "i1", "j", "k", "kj", "l", "lj", "m", "mj", "n", "nj", "o0",
"o1", "p", "pj", "r", "rj", "s", "sch", "sh", "sj", "t", "tj", "u0", "u1", "v",
"vj", "y0", "y1", "z", "zh", "zj"
).mapIndexed { index, phoneme -> phoneme to index.toLong() }.toMap()
}
}
@@ -218,12 +218,11 @@ class SettingsFragment : Fragment() {
val binding = _binding ?: return
val url = binding.qbooksUrlInput.text?.toString().orEmpty().trim()
val validation = QBooksUrlPolicy.validate(url)
val host = validation.host ?: url
val (text, color) = when {
url.isBlank() -> getString(R.string.settings_overview_qbooks_missing) to Color.parseColor("#6E5648")
!validation.isAllowed -> getString(R.string.settings_overview_qbooks_invalid) to Color.parseColor("#B64932")
validation.scheme == "https" -> getString(R.string.settings_overview_qbooks_https, host) to Color.parseColor("#2F7D5A")
validation.scheme == "http" -> getString(R.string.settings_overview_qbooks_local_http, host) to Color.parseColor("#9A672B")
validation.scheme == "https" -> getString(R.string.settings_overview_qbooks_https) to Color.parseColor("#2F7D5A")
validation.scheme == "http" -> getString(R.string.settings_overview_qbooks_local_http) to Color.parseColor("#9A672B")
else -> getString(R.string.settings_overview_qbooks_invalid) to Color.parseColor("#B64932")
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#252525" />
</shape>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#F0F0F0" />
</shape>
@@ -0,0 +1,204 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="18dp"
android:paddingEnd="24dp"
android:paddingBottom="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="64dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageButton
android:id="@+id/player_back_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/bg_player_soft_circle"
android:contentDescription="Назад"
android:padding="14dp"
android:rotation="-90"
android:src="@drawable/ic_reader_v2_chevron_down" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/player_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#222222"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_author"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#999999"
android:textSize="17sp" />
</LinearLayout>
<TextView
android:id="@+id/player_more_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/bg_player_soft_circle"
android:gravity="center"
android:text="⋮"
android:textColor="#252525"
android:textSize="28sp" />
</LinearLayout>
<ImageView
android:id="@+id/player_cover"
android:layout_width="280dp"
android:layout_height="280dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="34dp"
android:background="@drawable/bg_cover_placeholder"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/player_remaining_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="22dp"
android:gravity="center"
android:textColor="#A0A0A0"
android:textSize="17sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="92dp"
android:layout_marginTop="24dp"
android:gravity="center"
android:orientation="horizontal">
<TextView
android:id="@+id/player_speed_button"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:gravity="center"
android:text="1x"
android:textColor="#222222"
android:textSize="17sp" />
<TextView
android:id="@+id/player_rewind_button"
android:layout_width="0dp"
android:layout_height="64dp"
android:layout_weight="1"
android:gravity="center"
android:text="↶\n15"
android:textColor="#222222"
android:textSize="18sp" />
<TextView
android:id="@+id/player_play_button"
android:layout_width="76dp"
android:layout_height="76dp"
android:background="@drawable/bg_player_circle"
android:gravity="center"
android:paddingStart="5dp"
android:text="▶"
android:textColor="@android:color/white"
android:textSize="34sp" />
<TextView
android:id="@+id/player_forward_button"
android:layout_width="0dp"
android:layout_height="64dp"
android:layout_weight="1"
android:gravity="center"
android:text="↷\n30"
android:textColor="#222222"
android:textSize="18sp" />
<TextView
android:id="@+id/player_sleep_button"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:gravity="center"
android:text="◴"
android:textColor="#222222"
android:textSize="28sp" />
</LinearLayout>
<TextView
android:id="@+id/player_chapter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:maxLines="1"
android:text="Вся книга"
android:textColor="#222222"
android:textSize="18sp"
android:textStyle="bold" />
<SeekBar
android:id="@+id/player_seek_bar"
android:layout_width="match_parent"
android:layout_height="44dp"
android:layout_marginTop="12dp"
android:max="1000"
android:progressTint="#303030"
android:thumbTint="#303030" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/player_elapsed"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="00:00"
android:textColor="#9A9A9A"
android:textSize="16sp" />
<TextView
android:id="@+id/player_remaining"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="-00:00"
android:textColor="#9A9A9A"
android:textSize="16sp" />
</LinearLayout>
<Button
android:id="@+id/player_read_button"
android:layout_width="132dp"
android:layout_height="52dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="30dp"
android:backgroundTint="#252525"
android:text="Читать"
android:textAllCaps="false"
android:textColor="@android:color/white"
android:textSize="17sp" />
</LinearLayout>
</ScrollView>
@@ -192,16 +192,6 @@
android:layout_height="1dp"
android:layout_weight="1" />
<ImageButton
android:id="@+id/reader_voice_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/reader_v2_voice"
android:padding="12dp"
android:src="@drawable/ic_reader_v2_voice" />
<ImageButton
android:id="@+id/reader_search_button"
android:layout_width="48dp"
+1 -1
View File
@@ -26,7 +26,7 @@
android:layout_height="28dp"
android:contentDescription="@null"
android:src="@drawable/ic_reader_v2_bookmark"
android:tint="@color/reader_v2_accent" />
app:tint="@color/reader_v2_accent" />
<LinearLayout
android:layout_width="0dp"
@@ -26,7 +26,7 @@
android:layout_height="24dp"
android:contentDescription="@null"
android:src="@drawable/ic_reader_v2_quote"
android:tint="@color/reader_v2_accent" />
app:tint="@color/reader_v2_accent" />
<LinearLayout
android:layout_width="0dp"
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_create_audiobook"
android:title="Создать аудиокнигу" />
<item
android:id="@+id/action_share_book"
android:title="@string/action_share" />
@@ -1,40 +0,0 @@
package com.aletheia.app.ui.reader.tts
import org.junit.Assert.assertEquals
import org.junit.Test
class RussianNumberNormalizerTest {
@Test
fun `normalizes cardinal integers`() {
assertEquals("ноль один двадцать один", RussianNumberNormalizer.normalize("0 1 21"))
assertEquals("одна тысяча один", RussianNumberNormalizer.normalize("1001"))
assertEquals("две тысячи двадцать шесть", RussianNumberNormalizer.normalize("2026"))
assertEquals("двадцать две тысячи", RussianNumberNormalizer.normalize("22000"))
}
@Test
fun `preserves leading zeroes and reads decimal digits`() {
assertEquals("ноль ноль семь", RussianNumberNormalizer.normalize("007"))
assertEquals("минус двенадцать запятая ноль пять", RussianNumberNormalizer.normalize("-12,05"))
}
@Test
fun `normalizes number sign and non breaking groups`() {
assertEquals("номер пять", RussianNumberNormalizer.normalize("№ 5"))
assertEquals("двенадцать тысяч триста сорок пять", RussianNumberNormalizer.normalize("12\u00A0345"))
}
@Test
fun `does not rewrite digits inside words`() {
assertEquals("глава2 и abc123", RussianNumberNormalizer.normalize("глава2 и abc123"))
}
@Test
fun `inflects four digit years before году`() {
assertEquals(
"В две тысячи двадцать шестом году и в тысяча восемьсот девяносто седьмом году",
RussianNumberNormalizer.normalize("В 2026 году и в 1897 году")
)
assertEquals("В двухтысячном году", RussianNumberNormalizer.normalize("В 2000 году"))
}
}
@@ -1,27 +0,0 @@
package com.aletheia.app.ui.reader.tts
import org.junit.Assert.assertEquals
import org.junit.Test
class RussianSpeechTextSplitterTest {
@Test
fun keepsTerminalPunctuationAndDialogueDashInOneSpeechSentence() {
listOf(
"Стой. — сказал он.",
"Стой! — сказал он.",
"Стой? — спросил он.",
"«Стой!» — сказал он.",
"Стой? - спросил он."
).forEach { text ->
assertEquals(listOf(text), RussianSpeechTextSplitter.splitSentences(text))
}
}
@Test
fun stillSeparatesOrdinarySentences() {
assertEquals(
listOf("Первое предложение.", "Второе предложение!", "Третье?"),
RussianSpeechTextSplitter.splitSentences("Первое предложение. Второе предложение! Третье?")
)
}
}
+1 -1
View File
@@ -328,7 +328,7 @@ if ($RestartArgus) {
$remoteScript = $remoteScriptLines -join "`n"
$tempRemoteScript = New-TemporaryFile
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"
if ($LASTEXITCODE -ne 0) {
throw "ssh exited with code $LASTEXITCODE."
+49
View File
@@ -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
+688
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
imageio-ffmpeg==0.6.0
num2words==0.5.14
python-multipart==0.0.32
+20
View File
@@ -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
}