feat(phase4): port RSVP engine, audio player, TTS repo, workers from Book's Story
- RSVP: RsvpEngine, RsvpTokenizer, RsvpToken, ReaderText model (domain models ported). - Audio: AudioPlaybackService (@AndroidEntryPoint, ExoPlayer, MediaSession), AudioTrack, AudioPlayerProgress, Book stub. - TTS: RemoteTtsRepository interface + RemoteTtsRepositoryImpl (wraps BookshelfApiRepository). - Workers: no-op stubs for CacheDownloadWorker, ProgressSyncWorker, TtsDownloadWorker. - DI: PlaybackModule provides ExoPlayer singleton. - Strings: added audio_playback_channel resources. - ':app:assembleOssDebug' passes.
This commit is contained in:
parent
f7bffd0b06
commit
cacfe2c7c4
14 changed files with 689 additions and 0 deletions
|
|
@ -0,0 +1,118 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookreader.audio
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import androidx.media3.session.MediaSessionService
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.dueattendant149.bookreader.R
|
||||||
|
import org.dueattendant149.bookreader.audio.Book
|
||||||
|
import org.dueattendant149.bookreader.MainActivity
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class AudioPlaybackService : MediaSessionService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var exoPlayer: ExoPlayer
|
||||||
|
|
||||||
|
private var mediaSession: MediaSession? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
}
|
||||||
|
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
|
||||||
|
mediaSession = MediaSession.Builder(this, exoPlayer)
|
||||||
|
.setSessionActivity(sessionActivity)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent?.getParcelableExtra(EXTRA_BOOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = buildNotification(book)
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
return super.onStartCommand(intent, flags, startId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||||
|
return mediaSession
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
val player = mediaSession?.player ?: exoPlayer
|
||||||
|
if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) {
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
mediaSession?.run {
|
||||||
|
player.release()
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
mediaSession = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.audio_playback_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = getString(R.string.audio_playback_channel_description)
|
||||||
|
}
|
||||||
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(book: Book?): Notification {
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(book?.title ?: getString(R.string.app_name))
|
||||||
|
.setContentText(book?.author ?: "")
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_BOOK = "extra_book"
|
||||||
|
private const val CHANNEL_ID = "audio_playback"
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookreader.audio
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder for the audio player progress state.
|
||||||
|
*
|
||||||
|
* TODO: When implementing the ExoPlayer/MediaSession audio player,
|
||||||
|
* hook [org.dueattendant149.bookreader.domain.use_case.remote.SyncPlaybackProgressUseCase]
|
||||||
|
* into the playback position update flow, e.g.:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val book = ...
|
||||||
|
* val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile
|
||||||
|
* val position = player.currentPosition.coerceAtLeast(0L)
|
||||||
|
* val duration = player.duration.coerceAtLeast(book.audioDuration)
|
||||||
|
* syncPlaybackProgressUseCase(book, currentFile, position, duration)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
data class AudioPlayerProgress(
|
||||||
|
val bookId: Int,
|
||||||
|
val currentFile: String,
|
||||||
|
val position: Long,
|
||||||
|
val duration: Long,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.dueattendant149.bookreader.audio
|
||||||
|
|
||||||
|
data class AudioTrack(
|
||||||
|
val fileId: String,
|
||||||
|
val title: String,
|
||||||
|
val durationMs: Long,
|
||||||
|
val order: Int,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package org.dueattendant149.bookreader.audio
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Book model stub for AudioPlaybackService.
|
||||||
|
* Ported from Book's Story domain model.
|
||||||
|
*/
|
||||||
|
data class Book(
|
||||||
|
val id: Long = 0,
|
||||||
|
val title: String = "",
|
||||||
|
val author: String = "",
|
||||||
|
val remoteId: String = "",
|
||||||
|
val libraryId: String = "",
|
||||||
|
val hasAudio: Boolean = false,
|
||||||
|
val audioDuration: Long = 0L,
|
||||||
|
val audioCurrentFile: String = "",
|
||||||
|
val audioCurrentPosition: Long = 0L,
|
||||||
|
val coverUrl: String = "",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package org.dueattendant149.bookreader.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object PlaybackModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideExoPlayer(@ApplicationContext context: Context): ExoPlayer =
|
||||||
|
ExoPlayer.Builder(context).build()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package org.dueattendant149.bookreader.rsvp
|
||||||
|
|
||||||
|
sealed class ReaderText {
|
||||||
|
data class Paragraph(val text: String) : ReaderText()
|
||||||
|
data class Heading(val text: String) : ReaderText()
|
||||||
|
data class Chapter(val title: String) : ReaderText()
|
||||||
|
data class Separator(val text: String = "") : ReaderText()
|
||||||
|
data class Image(val alt: String = "") : ReaderText()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookreader.rsvp
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives RSVP playback for a fixed token stream.
|
||||||
|
*
|
||||||
|
* @param scope scope used for the playback loop (typically `viewModelScope`).
|
||||||
|
*/
|
||||||
|
class RsvpEngine(private val scope: CoroutineScope) {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val currentIndex: Int = 0,
|
||||||
|
val isPlaying: Boolean = false,
|
||||||
|
val tokens: List<RsvpToken> = emptyList(),
|
||||||
|
val wpm: Int = 350,
|
||||||
|
val pauseOnParagraphEnd: Boolean = true,
|
||||||
|
val pauseOnChapterEnd: Boolean = true,
|
||||||
|
val pauseOnLongWords: Boolean = true,
|
||||||
|
) {
|
||||||
|
val progress: Float
|
||||||
|
get() = if (tokens.isEmpty()) 0f
|
||||||
|
else currentIndex.toFloat() / tokens.lastIndex.coerceAtLeast(1)
|
||||||
|
|
||||||
|
val currentToken: RsvpToken?
|
||||||
|
get() = tokens.getOrNull(currentIndex)
|
||||||
|
|
||||||
|
val isAtEnd: Boolean
|
||||||
|
get() = tokens.isNotEmpty() && currentIndex >= tokens.lastIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var playbackJob: Job? = null
|
||||||
|
|
||||||
|
fun setTokens(tokens: List<RsvpToken>) {
|
||||||
|
stop()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
tokens = tokens,
|
||||||
|
currentIndex = 0,
|
||||||
|
isPlaying = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWpm(wpm: Int) {
|
||||||
|
_state.value = _state.value.copy(wpm = wpm.coerceIn(50, 1500))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPauseOnParagraphEnd(value: Boolean) {
|
||||||
|
_state.value = _state.value.copy(pauseOnParagraphEnd = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPauseOnChapterEnd(value: Boolean) {
|
||||||
|
_state.value = _state.value.copy(pauseOnChapterEnd = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPauseOnLongWords(value: Boolean) {
|
||||||
|
_state.value = _state.value.copy(pauseOnLongWords = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekToIndex(index: Int) {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.tokens.isEmpty()) return
|
||||||
|
val clamped = index.coerceIn(0, s.tokens.lastIndex)
|
||||||
|
_state.value = s.copy(currentIndex = clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekToProgress(progress: Float) {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.tokens.isEmpty()) return
|
||||||
|
val clamped = progress.coerceIn(0f, 1f)
|
||||||
|
val idx = (clamped * s.tokens.lastIndex).toInt()
|
||||||
|
_state.value = s.copy(currentIndex = idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipForward(count: Int = 10) {
|
||||||
|
seekToIndex(_state.value.currentIndex + count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipBackward(count: Int = 10) {
|
||||||
|
seekToIndex(_state.value.currentIndex - count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play() {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.tokens.isEmpty()) return
|
||||||
|
if (s.isAtEnd) {
|
||||||
|
seekToIndex(0)
|
||||||
|
}
|
||||||
|
if (_state.value.isPlaying) return
|
||||||
|
_state.value = _state.value.copy(isPlaying = true)
|
||||||
|
startPlaybackLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
_state.value = _state.value.copy(isPlaying = false)
|
||||||
|
playbackJob?.cancel()
|
||||||
|
playbackJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggle() {
|
||||||
|
if (_state.value.isPlaying) pause() else play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPlaybackLoop() {
|
||||||
|
playbackJob?.cancel()
|
||||||
|
playbackJob = scope.launch {
|
||||||
|
while (isActive && _state.value.isPlaying) {
|
||||||
|
val snapshot = _state.value
|
||||||
|
if (snapshot.isAtEnd) {
|
||||||
|
_state.value = snapshot.copy(isPlaying = false)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val token = snapshot.tokens[snapshot.currentIndex]
|
||||||
|
delay(delayMsFor(token, snapshot))
|
||||||
|
|
||||||
|
val after = _state.value
|
||||||
|
if (!after.isPlaying) return@launch
|
||||||
|
if (after.tokens.isEmpty()) return@launch
|
||||||
|
|
||||||
|
_state.value = after.copy(
|
||||||
|
currentIndex = (after.currentIndex + 1).coerceAtMost(after.tokens.lastIndex)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the delay (in ms) before advancing past [token].
|
||||||
|
*
|
||||||
|
* Base = 60_000 / wpm, adjusted by:
|
||||||
|
* - per-word multiplier (long words / digits)
|
||||||
|
* - paragraph-end bonus (configurable, default +200 ms)
|
||||||
|
* - chapter-end bonus (configurable, default +400 ms)
|
||||||
|
*/
|
||||||
|
private fun delayMsFor(token: RsvpToken, snapshot: State): Long {
|
||||||
|
val wpm = snapshot.wpm.coerceAtLeast(50)
|
||||||
|
val baseMs = 60_000.0 / wpm
|
||||||
|
var ms = baseMs * token.multiplier
|
||||||
|
|
||||||
|
if (token.isParagraphEnd && snapshot.pauseOnParagraphEnd) {
|
||||||
|
ms += 200.0
|
||||||
|
}
|
||||||
|
if (token.isChapterEnd && snapshot.pauseOnChapterEnd) {
|
||||||
|
ms += 400.0
|
||||||
|
}
|
||||||
|
if (token.multiplier > 1.2f && snapshot.pauseOnLongWords) {
|
||||||
|
// multiplier already applied above; keep it
|
||||||
|
} else if (token.multiplier > 1.2f && !snapshot.pauseOnLongWords) {
|
||||||
|
ms = baseMs
|
||||||
|
}
|
||||||
|
return ms.toLong().coerceAtLeast(20L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookreader.rsvp
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One word/segment in the RSVP stream.
|
||||||
|
*
|
||||||
|
* @param word the original word as it appears in the book.
|
||||||
|
* @param pivotIndex index of the optimal recognition point within [word];
|
||||||
|
* characters before are [prefix], the pivot is [pivot] char, after are [suffix].
|
||||||
|
* @param isParagraphEnd true if the token is the last word of a paragraph (longer pause).
|
||||||
|
* @param isChapterEnd true if the token follows a chapter heading.
|
||||||
|
* @param multiplier duration multiplier (1.0 baseline) for per-word pacing.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class RsvpToken(
|
||||||
|
val word: String,
|
||||||
|
val pivotIndex: Int,
|
||||||
|
val isParagraphEnd: Boolean,
|
||||||
|
val isChapterEnd: Boolean,
|
||||||
|
val multiplier: Float = 1f,
|
||||||
|
) {
|
||||||
|
val prefix: String
|
||||||
|
get() = word.substring(0, pivotIndex)
|
||||||
|
|
||||||
|
val pivot: String
|
||||||
|
get() = word.substring(pivotIndex, pivotIndex + 1)
|
||||||
|
|
||||||
|
val suffix: String
|
||||||
|
get() = word.substring(pivotIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimal recognition point (ORP) lookup for word lengths 1..13+.
|
||||||
|
* Indices are 0-based positions of the focal character.
|
||||||
|
* Values follow the heuristic table from Spritz/speed-reading literature:
|
||||||
|
* 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3.
|
||||||
|
*/
|
||||||
|
object OrpTable {
|
||||||
|
private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3)
|
||||||
|
|
||||||
|
fun pivotFor(word: String): Int {
|
||||||
|
val len = word.length
|
||||||
|
if (len == 0) return 0
|
||||||
|
if (len <= TABLE.size) return TABLE[len - 1]
|
||||||
|
return (len * 0.3f).toInt().coerceIn(1, len - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookreader.rsvp
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.rsvp.ReaderText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader)
|
||||||
|
* into a flat list of [RsvpToken]s suitable for RSVP playback.
|
||||||
|
*
|
||||||
|
* - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image].
|
||||||
|
* - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`.
|
||||||
|
* - Marks the first word after a chapter heading as `isChapterEnd = true`.
|
||||||
|
* - Strips punctuation-only tokens and empty whitespace tokens.
|
||||||
|
*/
|
||||||
|
object RsvpTokenizer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize a list of [ReaderText] blocks (as produced by the reader parser).
|
||||||
|
*/
|
||||||
|
fun tokenizeReaderText(blocks: List<ReaderText>): List<RsvpToken> {
|
||||||
|
val tokens = ArrayList<RsvpToken>(blocks.size * 8)
|
||||||
|
var lastWasParagraphEnd = false
|
||||||
|
var pendingChapterEnd = false
|
||||||
|
|
||||||
|
for (block in blocks) {
|
||||||
|
when (block) {
|
||||||
|
is ReaderText.Chapter -> {
|
||||||
|
pendingChapterEnd = true
|
||||||
|
lastWasParagraphEnd = false
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReaderText.Paragraph -> {
|
||||||
|
val raw = block.text
|
||||||
|
if (raw.isBlank()) {
|
||||||
|
lastWasParagraphEnd = true
|
||||||
|
} else {
|
||||||
|
tokenizeLine(
|
||||||
|
line = raw,
|
||||||
|
isParagraphEnd = lastWasParagraphEnd,
|
||||||
|
isChapterEnd = pendingChapterEnd,
|
||||||
|
out = tokens,
|
||||||
|
)
|
||||||
|
lastWasParagraphEnd = false
|
||||||
|
pendingChapterEnd = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReaderText.Heading -> {
|
||||||
|
val raw = block.text
|
||||||
|
if (raw.isNotBlank()) {
|
||||||
|
tokenizeLine(
|
||||||
|
line = raw,
|
||||||
|
isParagraphEnd = lastWasParagraphEnd,
|
||||||
|
isChapterEnd = pendingChapterEnd,
|
||||||
|
out = tokens,
|
||||||
|
)
|
||||||
|
lastWasParagraphEnd = false
|
||||||
|
pendingChapterEnd = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReaderText.Separator -> lastWasParagraphEnd = true
|
||||||
|
is ReaderText.Image -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tokenizeLine(
|
||||||
|
line: String,
|
||||||
|
isParagraphEnd: Boolean,
|
||||||
|
isChapterEnd: Boolean,
|
||||||
|
out: MutableList<RsvpToken>,
|
||||||
|
) {
|
||||||
|
val words = line.split(WORD_SPLIT_REGEX).filter { it.isNotBlank() }
|
||||||
|
if (words.isEmpty()) return
|
||||||
|
|
||||||
|
val lastIndex = words.lastIndex
|
||||||
|
for ((idx, word) in words.withIndex()) {
|
||||||
|
val cleaned = cleanWord(word)
|
||||||
|
if (cleaned.isEmpty()) continue
|
||||||
|
val pivot = OrpTable.pivotFor(cleaned)
|
||||||
|
val paragraphEnd = isParagraphEnd && idx == lastIndex
|
||||||
|
val multiplier = computeMultiplier(cleaned)
|
||||||
|
out.add(
|
||||||
|
RsvpToken(
|
||||||
|
word = cleaned,
|
||||||
|
pivotIndex = pivot,
|
||||||
|
isParagraphEnd = paragraphEnd,
|
||||||
|
isChapterEnd = isChapterEnd && idx == 0,
|
||||||
|
multiplier = multiplier,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val WORD_SPLIT_REGEX = Regex("\\s+")
|
||||||
|
|
||||||
|
private fun cleanWord(raw: String): String {
|
||||||
|
val sb = StringBuilder(raw.length)
|
||||||
|
var i = 0
|
||||||
|
val len = raw.length
|
||||||
|
while (i < len) {
|
||||||
|
val c = raw[i]
|
||||||
|
if (c.isLetterOrDigit() || c == '-' || c == '\'') {
|
||||||
|
sb.append(c)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-word pacing multiplier.
|
||||||
|
*
|
||||||
|
* - Long words (>9 chars) get +30% time for recognition.
|
||||||
|
* - Numbers/digits get +20% time.
|
||||||
|
* - Baseline 1.0 for normal words.
|
||||||
|
*/
|
||||||
|
private fun computeMultiplier(word: String): Float {
|
||||||
|
val len = word.length
|
||||||
|
val base = when {
|
||||||
|
len > 9 -> 1.3f
|
||||||
|
len > 6 -> 1.1f
|
||||||
|
else -> 1.0f
|
||||||
|
}
|
||||||
|
val hasDigit = word.any { it.isDigit() }
|
||||||
|
return if (hasDigit) base + 0.2f else base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package org.dueattendant149.bookreader.tts
|
||||||
|
|
||||||
|
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
|
||||||
|
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
|
||||||
|
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
|
||||||
|
interface RemoteTtsRepository {
|
||||||
|
suspend fun fetchEngines(): Result<TtsEnginesResponse>
|
||||||
|
suspend fun fetchVoices(engine: String? = null): Result<TtsVoicesResponse>
|
||||||
|
suspend fun createJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result<TtsJobResponse>
|
||||||
|
suspend fun getJob(jobId: String): Result<TtsJobResponse>
|
||||||
|
suspend fun downloadAudio(jobId: String): Result<ResponseBody>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.dueattendant149.bookreader.tts
|
||||||
|
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
|
||||||
|
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest
|
||||||
|
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
|
||||||
|
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
|
||||||
|
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class RemoteTtsRepositoryImpl
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val bookshelfApi: BookshelfApiRepository,
|
||||||
|
) : RemoteTtsRepository {
|
||||||
|
override suspend fun fetchEngines(): Result<TtsEnginesResponse> = bookshelfApi.getTtsEngines()
|
||||||
|
override suspend fun fetchVoices(engine: String?): Result<TtsVoicesResponse> = bookshelfApi.getTtsVoices(engine)
|
||||||
|
override suspend fun createJob(request: TtsCreateRequest): Result<TtsJobResponse> = bookshelfApi.createTtsJob(request)
|
||||||
|
override suspend fun getJob(jobId: String): Result<TtsJobResponse> = bookshelfApi.getTtsJobStatus(jobId)
|
||||||
|
override suspend fun downloadAudio(jobId: String): Result<ResponseBody> = bookshelfApi.downloadTtsAudio(jobId)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookreader.tts
|
||||||
|
|
||||||
|
data class TtsEngine(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val supportsStreaming: Boolean,
|
||||||
|
val supportsCloning: Boolean,
|
||||||
|
val maxTextLength: Int,
|
||||||
|
val needsNetwork: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TtsVoice(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val language: String,
|
||||||
|
val engine: String,
|
||||||
|
val gender: String,
|
||||||
|
val quality: String,
|
||||||
|
val requiresReference: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TtsJob(
|
||||||
|
val jobId: String,
|
||||||
|
val bookId: String,
|
||||||
|
val title: String,
|
||||||
|
val author: String,
|
||||||
|
val engine: String,
|
||||||
|
val voiceId: String,
|
||||||
|
val speed: Double,
|
||||||
|
val status: String,
|
||||||
|
val progress: Double,
|
||||||
|
val currentChapter: String,
|
||||||
|
val totalChapters: Int,
|
||||||
|
val completedChapters: Int,
|
||||||
|
val outputPath: String,
|
||||||
|
val error: String,
|
||||||
|
val createdAt: Double,
|
||||||
|
val startedAt: Double,
|
||||||
|
val completedAt: Double,
|
||||||
|
) {
|
||||||
|
val isCompleted: Boolean get() = status == "completed"
|
||||||
|
val isFailed: Boolean get() = status == "failed" || status == "error"
|
||||||
|
val isRunning: Boolean get() = status == "running" || status == "queued" || status == "pending"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package org.dueattendant149.bookreader.work
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub for CacheDownloadWorker. No-op until full Book's Story data layer is ported.
|
||||||
|
*/
|
||||||
|
class CacheDownloadWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
override suspend fun doWork(): Result = Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub for ProgressSyncWorker. No-op until full Book's Story data layer is ported.
|
||||||
|
*/
|
||||||
|
class ProgressSyncWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
override suspend fun doWork(): Result = Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub for TtsDownloadWorker. No-op until full Book's Story data layer is ported.
|
||||||
|
*/
|
||||||
|
class TtsDownloadWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
override suspend fun doWork(): Result = Result.success()
|
||||||
|
}
|
||||||
|
|
@ -2066,4 +2066,6 @@
|
||||||
<string name="tts_replacements_replace_only_spoken_desc">Reader text, highlights, and locations stay unchanged.</string>
|
<string name="tts_replacements_replace_only_spoken_desc">Reader text, highlights, and locations stay unchanged.</string>
|
||||||
<!-- TTS replacement summary. %1$s = replacement source; %2$s = spoken replacement. Example: "Dr. -> Doctor". -->
|
<!-- TTS replacement summary. %1$s = replacement source; %2$s = spoken replacement. Example: "Dr. -> Doctor". -->
|
||||||
<string name="tts_replacements_summary_format">%1$s -> %2$s</string>
|
<string name="tts_replacements_summary_format">%1$s -> %2$s</string>
|
||||||
|
<string name="audio_playback_channel">Audio Playback</string>
|
||||||
|
<string name="audio_playback_channel_description">Audio playback controls</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue