From 0ed13a4b6621bda26cff41e0372856f627830876 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:40:03 +0530 Subject: [PATCH] fix(tts): prefer an installed voice for locale-only tags For a locale-only tag such as {{tts fr_FR:Front}}, voiceForTag fell back on the first voice matching the language without checking availability. Android can list voices flagged KEY_FEATURE_NOT_INSTALLED, so an uninstalled voice could be chosen and fail on playback with ERROR_NOT_INSTALLED_YET even when an installed voice for that language was available. The language fallback now prefers an installed voice, only settling for an unavailable one when nothing else matches. Assisted-by: Claude Opus 4.8 - Writing tests --- .../main/java/com/ichi2/anki/libanki/Tts.kt | 14 ++- .../java/com/ichi2/anki/libanki/TtsTest.kt | 87 +++++++++++++++++++ 2 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 libanki/src/test/java/com/ichi2/anki/libanki/TtsTest.kt diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Tts.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Tts.kt index 6d243db6f746..3ea2189c9121 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Tts.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Tts.kt @@ -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) } } } diff --git a/libanki/src/test/java/com/ichi2/anki/libanki/TtsTest.kt b/libanki/src/test/java/com/ichi2/anki/libanki/TtsTest.kt new file mode 100644 index 000000000000..459bb3900da2 --- /dev/null +++ b/libanki/src/test/java/com/ichi2/anki/libanki/TtsTest.kt @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 Ashish Yadav +// 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 = voices.toList() + + override suspend fun play(tag: TTSTag): TtsCompletionStatus = TtsCompletionStatus.success() + + override fun close() {} + } +}