Skip to content
Open
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
14 changes: 6 additions & 8 deletions libanki/src/main/java/com/ichi2/anki/libanki/Tts.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,11 @@ abstract class TtsPlayer : Closeable {
rank -= 1
}

// if no preferred voices match, we fall back on language
// with a rank of -100
for (avail in availVoices) {
if (avail.lang == tag.lang) {
return TtsVoiceMatch(voice = avail, rank = -100)
}
}
return null
// No requested voice matched, so fall back on the language with a rank of -100.
// Prefer one that's actually installed: Android lists voices flagged unavailable
// (KEY_FEATURE_NOT_INSTALLED) that fail on playback, so only use one as a last resort (#21372)
val langMatches = availVoices.filter { it.lang == tag.lang }
val fallbackVoice = langMatches.firstOrNull { !it.unavailable() } ?: langMatches.firstOrNull()
return fallbackVoice?.let { TtsVoiceMatch(voice = it, rank = -100) }
}
}
87 changes: 87 additions & 0 deletions libanki/src/test/java/com/ichi2/anki/libanki/TtsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: 2026 Ashish Yadav <mailtoashish693@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later

package com.ichi2.anki.libanki

import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.notNullValue
import org.hamcrest.CoreMatchers.nullValue
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test

/** Test for [TtsPlayer.voiceForTag] */
class TtsTest {
@Test
fun `locale-only match prefers an installed voice over an unavailable one`() {
val unavailable = testVoice(name = "fr-unavailable", lang = "fr_FR", unavailable = true)
val installed = testVoice(name = "fr-installed", lang = "fr_FR", unavailable = false)
val player = testPlayer(unavailable, installed)

val match = player.voiceForTag(ttsTag("fr_FR"))

assertThat(match, notNullValue())
assertThat("locale-only selection should skip the unavailable voice", match!!.voice, equalTo(installed))
}

@Test
fun `locale-only match falls back to an unavailable voice when nothing else is installed`() {
val unavailable = testVoice(name = "fr-unavailable", lang = "fr_FR", unavailable = true)
val player = testPlayer(unavailable)

val match = player.voiceForTag(ttsTag("fr_FR"))

assertThat(match?.voice, equalTo(unavailable))
}

@Test
fun `locale-only match keeps the first installed voice when several are installed`() {
val first = testVoice(name = "fr-1", lang = "fr_FR", unavailable = false)
val second = testVoice(name = "fr-2", lang = "fr_FR", unavailable = false)
val player = testPlayer(first, second)

assertThat(player.voiceForTag(ttsTag("fr_FR"))?.voice, equalTo(first))
}

@Test
fun `locale-only fallback keeps the first unavailable voice when none are installed`() {
val first = testVoice(name = "fr-1", lang = "fr_FR", unavailable = true)
val second = testVoice(name = "fr-2", lang = "fr_FR", unavailable = true)
val player = testPlayer(first, second)

assertThat(player.voiceForTag(ttsTag("fr_FR"))?.voice, equalTo(first))
}

@Test
fun `no match when no voice shares the language`() {
val player = testPlayer(testVoice(name = "en", lang = "en_US", unavailable = false))

assertThat(player.voiceForTag(ttsTag("fr_FR")), nullValue())
}

@Test
fun `no match when there are no voices at all`() {
val player = testPlayer()

assertThat(player.voiceForTag(ttsTag("fr_FR")), nullValue())
}

private fun ttsTag(lang: String) =
TTSTag(fieldText = "bonjour", lang = lang, voices = emptyList(), speed = null, otherArgs = emptyList())

private fun testVoice(
name: String,
lang: String,
unavailable: Boolean,
) = object : TtsVoice(name = name, lang = lang) {
override fun unavailable(): Boolean = unavailable
}

private fun testPlayer(vararg voices: TtsVoice) =
object : TtsPlayer() {
override fun getAvailableVoices(): List<TtsVoice> = voices.toList()

override suspend fun play(tag: TTSTag): TtsCompletionStatus = TtsCompletionStatus.success()

override fun close() {}
}
}
Loading