Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ actual object TTSEngineFactory : KoinComponent {
null
}
}

/**
* Kokoro TTS requires a Python runtime and is desktop-only.
* Android falls back to the native engine; callers should handle null.
*/
actual fun createKokoroEngine(): TTSEngine? = null

/**
* Get cache statistics
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,32 @@ class AppPreferences(
fun selectedKokoroVoice(): Preference<String> {
return preferenceStore.getString("selected_kokoro_voice", "af_bella")
}

/**
* Whether Kokoro TTS is installed and verified. Persists across launches so the user
* doesn't re-run Install every time. True after a successful install/verify; false
* only on uninstall or failed verification.
*/
fun kokoroAvailable(): Preference<Boolean> {
return preferenceStore.getBoolean("kokoro_available", false)
}

/**
* User's preferred TTS engine for reading. Values: "PIPER", "KOKORO", "GRADIO",
* "SIMULATION" (matches DesktopTTSService.TTSEngine and v2 EngineType names).
*/
fun selectedTTSEngine(): Preference<String> {
return preferenceStore.getString("selected_tts_engine", "PIPER")
}

/**
* Optional override for the Python interpreter Kokoro TTS uses. Empty = auto-discover
* (PATH + common install locations). Set from Settings -> TTS if Python lives somewhere
* non-standard (pyenv, conda, a venv, a system package). Must be Python 3.8-3.12.
*/
fun kokoroPythonPath(): Preference<String> {
return preferenceStore.getString("kokoro_python_path", "")
}

/**
* Selected AI TTS provider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,16 @@ class TTSController(
private val nativeEngineFactory: () -> TTSEngine,
private val gradioEngineFactory: ((GradioConfig) -> TTSEngine?)? = null,
initialGradioConfig: GradioConfig? = null,
private val cacheUseCase: TTSCacheUseCase? = null
private val cacheUseCase: TTSCacheUseCase? = null,
// Produces a Kokoro engine on desktop when the user has installed it; returns null
// on platforms or configurations where Kokoro isn't available. When null, selecting
// `EngineType.KOKORO` falls back to the native engine with a warning.
private val kokoroEngineFactory: (() -> TTSEngine?)? = null,
// Optional persistence hooks so the user's selected engine survives app restarts.
// When provided, the controller loads the saved engine on first `initialize()` and
// writes the new value whenever `setEngine` actually switches.
private val persistedEngineType: (() -> EngineType?)? = null,
private val saveEngineType: ((EngineType) -> Unit)? = null
) {
// Mutable Gradio config that can be updated at runtime
private var gradioConfig: GradioConfig? = initialGradioConfig
Expand Down Expand Up @@ -87,8 +96,12 @@ class TTSController(
// Mutex to ensure commands are processed sequentially
private val commandMutex = Mutex()

// State - single source of truth
private val _state = MutableStateFlow(TTSState())
// State - single source of truth. Seeded from the persisted engine-type pref so
// the UI immediately reflects the user's last choice instead of defaulting to
// NATIVE and requiring a play() to hydrate via initialize().
private val _state = MutableStateFlow(
TTSState(engineType = persistedEngineType?.invoke() ?: EngineType.NATIVE)
)
val state: StateFlow<TTSState> = _state.asStateFlow()

// Events - one-time occurrences
Expand Down Expand Up @@ -170,14 +183,32 @@ class TTSController(

private fun initialize() {
Log.debug { "$TAG: initialize()" }


// On first init, hydrate the engineType from persisted prefs (if available).
// We do this lazily inside initialize() rather than the constructor so we don't
// risk Koin/DI not-yet-ready access from a secondary thread.
if (engine == null) {
val saved = persistedEngineType?.invoke()
if (saved != null && saved != _state.value.engineType) {
_state.update { it.copy(engineType = saved) }
}
}

if (engine == null) {
val currentEngineType = _state.value.engineType
engine = when (currentEngineType) {
EngineType.NATIVE -> {
Log.debug { "$TAG: Creating native engine" }
nativeEngineFactory()
}
EngineType.KOKORO -> {
Log.warn { "$TAG: Creating Kokoro engine" }
kokoroEngineFactory?.invoke() ?: run {
Log.warn { "$TAG: Kokoro unavailable, falling back to native" }
_state.update { it.copy(engineType = EngineType.NATIVE) }
nativeEngineFactory()
}
}
EngineType.GRADIO -> {
Log.debug { "$TAG: Creating Gradio engine" }
val config = gradioConfig
Expand Down Expand Up @@ -800,17 +831,20 @@ class TTSController(
private fun setEngine(type: EngineType) {
val currentState = _state.value
if (currentState.engineType == type) return

Log.debug { "$TAG: setEngine($type) - switching from ${currentState.engineType}" }

// Stop current engine
engine?.stop()
engine?.release()
engine = null

// Update state with new engine type
_state.update { it.copy(engineType = type, isEngineReady = false) }


// Persist the selection so it survives relaunches.
saveEngineType?.invoke(type)

// Create new engine will happen on next play() call via initialize()
// Or we can initialize immediately
initialize()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ sealed class EngineEvent {
expect object TTSEngineFactory {
fun createNativeEngine(): TTSEngine
fun createGradioEngine(config: GradioConfig): TTSEngine?
/**
* Create a Kokoro TTS engine. Returns null if Kokoro is unavailable on this platform
* (e.g. Android) or not yet installed. The TTSController falls back to the native
* engine when this returns null.
*/
fun createKokoroEngine(): TTSEngine?
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package ireader.domain.services.tts_service.v2
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.koin.dsl.module

/**
Expand Down Expand Up @@ -49,13 +51,44 @@ val ttsV2Module = module {
// Using single instead of factory to maintain state across the app
// No ChapterController - TTS has its own independent state, sync happens via onPop
single {
TTSController(
val appPrefs: ireader.domain.preferences.prefs.AppPreferences = get()
// Legacy DesktopTTSService writes TTSEngine.{PIPER,KOKORO,GRADIO,SIMULATION};
// V2 speaks EngineType.{NATIVE,KOKORO,GRADIO}. This mapping bridges both surfaces
// so whichever UI the user picks from, the other reflects it on next read.
fun nameToEngineType(name: String): EngineType? = when (name) {
"NATIVE", "PIPER", "SIMULATION" -> EngineType.NATIVE
"KOKORO" -> EngineType.KOKORO
"GRADIO" -> EngineType.GRADIO
else -> null
}

val controller = TTSController(
contentLoader = get(),
nativeEngineFactory = { TTSEngineFactory.createNativeEngine() },
gradioEngineFactory = { config -> TTSEngineFactory.createGradioEngine(config) },
initialGradioConfig = null, // Can be set via SetGradioConfig command
cacheUseCase = getOrNull() // Optional - for offline playback of cached audio
cacheUseCase = getOrNull(), // Optional - for offline playback of cached audio
kokoroEngineFactory = { TTSEngineFactory.createKokoroEngine() },
persistedEngineType = { nameToEngineType(appPrefs.selectedTTSEngine().get()) },
saveEngineType = { appPrefs.selectedTTSEngine().set(it.name) }
)

// Reactively mirror pref changes into V2 state. The legacy TTS Engine Manager
// settings screen writes the pref via DesktopTTSService.setEngine, and the
// reader's TTSSettings drawer writes the same pref via V2's saveEngineType.
// Without this observer, the two surfaces drift: the user picks Kokoro from
// settings but the reader's engine label still says "Native TTS".
val syncScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
syncScope.launch {
appPrefs.selectedTTSEngine().changes().collect { name ->
val type = nameToEngineType(name) ?: return@collect
if (controller.state.value.engineType != type) {
controller.dispatch(ireader.domain.services.tts_service.v2.TTSCommand.SetEngine(type))
}
}
}

controller
}

// ViewModel adapter for UI layer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ class TTSNotificationUseCase(
speed = state.speed,
ttsProvider = when (state.engineType) {
EngineType.NATIVE -> "Native TTS"
EngineType.KOKORO -> "Kokoro TTS"
EngineType.GRADIO -> "Gradio TTS"
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ enum class PlaybackState {
}

enum class EngineType {
NATIVE, // Platform native TTS (Android TTS, AVSpeechSynthesizer, etc.)
NATIVE, // Platform native TTS (Android TTS on Android, Piper on desktop)
KOKORO, // Kokoro Python TTS (desktop only; falls back to native on other platforms)
GRADIO // Remote Gradio-based TTS (Coqui, etc.)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,15 @@ class DesktopTTSService : KoinComponent {
}
}

// TTS Engine selection
// TTS Engine selection. In-memory value is loaded from preferences in initialize()
// and written back to preferences in setEngine() so the selection persists across launches.
private var currentEngine: TTSEngine = TTSEngine.PIPER
var kokoroAvailable = false

// Backed by preferences so the install state persists across app launches.
// Once Kokoro has been verified as installed, the user shouldn't have to click Install again.
var kokoroAvailable: Boolean
get() = appPrefs.kokoroAvailable().get()
set(value) { appPrefs.kokoroAvailable().set(value) }

lateinit var state: DesktopTTSState
private var serviceJob: Job? = null
Expand Down Expand Up @@ -114,6 +120,10 @@ class DesktopTTSService : KoinComponent {
fun initialize() {
state = DesktopTTSState()
readPrefs()

// Load persisted engine selection so the user's choice survives a relaunch.
currentEngine = runCatching { TTSEngine.valueOf(appPrefs.selectedTTSEngine().get()) }
.getOrDefault(TTSEngine.PIPER)

// Record session start for analytics
usageAnalytics.recordSessionStart()
Expand Down Expand Up @@ -143,30 +153,49 @@ class DesktopTTSService : KoinComponent {
// Check if Kokoro is already installed (don't auto-install)
try {
val kokoroDir = java.io.File(ireader.core.storage.AppDir, "kokoro/kokoro-tts")

// Only initialize if FULLY installed (repo exists AND dependencies installed)
if (kokoroDir.exists() && kokoroDir.listFiles()?.isNotEmpty() == true) {
Log.info { "Found Kokoro repository, checking if fully installed..." }

// Check if dependencies are installed without triggering installation
// Try to run kokoro --help to verify it's working
val pythonCheck = ProcessBuilder(
"python", "-m", "kokoro", "--help"
).directory(kokoroDir)
.redirectErrorStream(true)
.start()

val checkCompleted = pythonCheck.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)
val output = pythonCheck.inputStream.bufferedReader().readText()

if (checkCompleted && pythonCheck.exitValue() == 0 && output.contains("--voice")) {
kokoroAvailable = true
Log.info { "Kokoro TTS available (fully installed)" }
} else {
Log.info { "Kokoro repository found but not fully installed (user can complete installation from TTS Manager)" }
val persistedAvailable = kokoroAvailable
val repoPresent = kokoroDir.exists() && kokoroDir.listFiles()?.isNotEmpty() == true

when {
// Pref says it's installed and the repo still exists on disk: trust the pref.
// Skip the expensive 5-second python subprocess check at startup so the
// user isn't forced to click Install again after every launch.
persistedAvailable && repoPresent -> {
Log.info { "Kokoro TTS available (persisted from previous install)" }
}
// Pref said installed but the repo is gone — user nuked ~/.cache. Reset flag.
persistedAvailable && !repoPresent -> {
Log.warn { "Kokoro marked installed but repository missing — resetting flag" }
kokoroAvailable = false
}
// No prior install recorded: do the classic verify-on-launch dance.
repoPresent -> {
Log.info { "Found Kokoro repository, checking if fully installed..." }

// Try to run kokoro --help to verify it's working. Use `python3` so
// the check honours the same PATH shim mechanism Kokoro's own
// findPythonExecutable() uses (it prefers `python3` over `python`).
// On Arch, /usr/bin/python is the system 3.13 interpreter, and going
// through `python` would bypass any venv shim the user has set up.
val pythonCheck = ProcessBuilder(
"python3", "-m", "kokoro", "--help"
).directory(kokoroDir)
.redirectErrorStream(true)
.start()

val checkCompleted = pythonCheck.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)
val output = pythonCheck.inputStream.bufferedReader().readText()

if (checkCompleted && pythonCheck.exitValue() == 0 && output.contains("--voice")) {
kokoroAvailable = true
Log.info { "Kokoro TTS available (verified at startup, flag persisted)" }
} else {
Log.info { "Kokoro repository found but not fully installed (user can complete installation from TTS Manager)" }
}
}
else -> {
Log.info { "Kokoro not installed (user must install from TTS Manager)" }
}
} else {
Log.info { "Kokoro not installed (user must install from TTS Manager)" }
}
} catch (e: Exception) {
Log.debug { "Kokoro check: ${e.message}" }
Expand Down Expand Up @@ -1944,6 +1973,7 @@ class DesktopTTSService : KoinComponent {
* Set TTS engine
*/
fun setEngine(engine: TTSEngine) {
val previous = currentEngine
when (engine) {
TTSEngine.PIPER -> {
if (synthesizer.isInitialized()) {
Expand All @@ -1969,7 +1999,7 @@ class DesktopTTSService : KoinComponent {
Log.info { "Gradio not available, trying to configure from preferences..." }
configureGradioFromPreferences()
}

if (gradioAvailable && gradioPlayer != null) {
currentEngine = TTSEngine.GRADIO
isSimulationMode = false
Expand All @@ -1984,6 +2014,11 @@ class DesktopTTSService : KoinComponent {
Log.info { "Switched to Simulation mode" }
}
}
// Persist the selection only if the switch actually took effect (i.e. the engine was
// available). Refusing an unavailable engine leaves the previous selection untouched.
if (currentEngine != previous) {
appPrefs.selectedTTSEngine().set(currentEngine.name)
}
}

/**
Expand Down Expand Up @@ -2088,9 +2123,9 @@ class DesktopTTSService : KoinComponent {
if (kokoroDir.exists() && kokoroDir.listFiles()?.isNotEmpty() == true) {
Log.info { "Found Kokoro repository, checking if fully installed..." }

// Check if dependencies are installed
// Check if dependencies are installed (use python3 to honour PATH shim)
val pythonCheck = ProcessBuilder(
"python", "-m", "kokoro", "--help"
"python3", "-m", "kokoro", "--help"
).directory(kokoroDir)
.redirectErrorStream(true)
.start()
Expand Down
Loading