Skip to content

Commit 426ad46

Browse files
author
Ahmed Mohamed
committed
Prepare v2.5.1 stable release
1 parent c093566 commit 426ad46

5 files changed

Lines changed: 180 additions & 45 deletions

File tree

app/build.gradle.kts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,18 @@ android {
3131
applicationId = "dev.ahmedmohamed.hayaitts"
3232
minSdk = 26
3333
targetSdk = 36
34-
versionCode = 203
34+
versionCode = 204
3535
// `HAYAITTS_VERSION_NAME` is exported by the release workflow as the
3636
// git tag (`v2.0.0`, `v2.0.0-b3`, or `r142`) so the installed APK's
3737
// `BuildConfig.VERSION_NAME` matches the release the user downloaded.
3838
// Without it a locally-built or workflow-built nightly would always
39-
// read "2.5.0", which is what the Settings channel pill used to
40-
// show regardless of which build the user was actually running.
39+
// read the fallback version, which is what the Settings channel pill
40+
// used to show regardless of which build the user was actually running.
4141
versionName = System.getenv("HAYAITTS_VERSION_NAME")
4242
?.trim()
4343
?.takeIf { it.isNotEmpty() }
4444
?.removePrefix("v")
45-
?: "2.5.0"
45+
?: "2.5.1"
4646

4747
// Room schema export. KSP picks this up via the `room` argument and
4848
// writes JSON snapshots of each entity into app/schemas/. Schemas are

app/src/main/java/dev/ahmedmohamed/hayaitts/data/download/VoiceDownloadWorker.kt

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -396,15 +396,17 @@ class VoiceDownloadWorker(
396396
requireOneOf(voice.modelFileName ?: "model-steps-3.onnx", acoustic)
397397
if (!File(dir, tokens).isFile) missing += tokens
398398
// Vocoder is downloaded as a sidecar — fail if it didn't land.
399-
val vocoderName = voice.vocoderFileName ?: "vocos-22khz-univ.onnx"
400-
if (!File(dir, vocoderName).isFile) missing += vocoderName
399+
val vocoderCandidates = listOfNotNull(voice.vocoderFileName) + MATCHA_VOCODER_CANDIDATES
400+
requireOneOf(voice.vocoderFileName ?: "vocos-22khz-univ.onnx", vocoderCandidates)
401401
}
402402
ModelFamily.KOKORO -> {
403403
// Kokoro bundles ship `model.onnx` for the English release and
404-
// `kokoro-multi-lang-v1_0.onnx` for the multilingual one.
404+
// `kokoro-multi-lang-v1_*.onnx` for multilingual releases.
405405
val candidates = listOfNotNull(
406406
voice.modelFileName, "model.onnx",
407-
"kokoro-multi-lang-v1_0.onnx", "kokoro-en-v0_19.onnx",
407+
"kokoro-multi-lang-v1_1.onnx", "kokoro-multi-lang-v1_0.onnx",
408+
"kokoro-int8-multi-lang-v1_1.onnx", "kokoro-int8-multi-lang-v1_0.onnx",
409+
"kokoro-en-v0_19.onnx",
408410
)
409411
requireOneOf(voice.modelFileName ?: "model.onnx", candidates)
410412
if (!File(dir, tokens).isFile) missing += tokens
@@ -635,6 +637,12 @@ class VoiceDownloadWorker(
635637
/** Shared embedding bank shipped by Kokoro and Kitten releases. */
636638
private const val KOKORO_VOICES_FILE = "voices.bin"
637639

640+
private val MATCHA_VOCODER_CANDIDATES = listOf(
641+
"vocos-22khz-univ.onnx",
642+
"vocos-16khz-univ.onnx",
643+
"vocoder.onnx",
644+
)
645+
638646
/** Max WorkManager attempts (counting from 0). */
639647
private const val MAX_RETRIES = 3
640648

app/src/main/java/dev/ahmedmohamed/hayaitts/tts/HayaiTtsService.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ class HayaiTtsService : TextToSpeechService() {
138138
}
139139

140140
val requestVoiceName = request.voiceName ?: lastSelectedVoiceId
141+
val requestLanguageHint = buildBcp47(
142+
request.language,
143+
request.country,
144+
request.variant,
145+
).ifBlank { null }
141146
val (selectedVoiceId, selectedSpeakerId) = parseVoiceName(requestVoiceName)
142147
val (fallbackVoiceId, _) = parseVoiceName(lastSelectedVoiceId)
143148

@@ -179,7 +184,7 @@ class HayaiTtsService : TextToSpeechService() {
179184
?: 0
180185

181186
val sampleRate = try {
182-
runtime.sampleRateOf(voice.voiceId)
187+
runtime.sampleRateOf(voice.voiceId, languageHint = requestLanguageHint)
183188
} catch (t: Throwable) {
184189
log.e(t) { "Could not load voice ${voice.voiceId}" }
185190
callback.error()
@@ -202,6 +207,7 @@ class HayaiTtsService : TextToSpeechService() {
202207
lengthScale = tuning.lengthScale,
203208
noiseScale = tuning.noiseScale,
204209
noiseScaleW = tuning.noiseScaleW,
210+
languageHint = requestLanguageHint,
205211
)
206212
} catch (t: Throwable) {
207213
log.e(t) { "sherpa-onnx synthesis failed" }

app/src/main/java/dev/ahmedmohamed/hayaitts/tts/SherpaTtsRuntime.kt

Lines changed: 124 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class SherpaTtsRuntime private constructor(
4040

4141
private data class EngineKey(
4242
val voiceId: String,
43+
val kokoroLanguage: String,
4344
val lengthBucket: Int,
4445
val noiseBucket: Int,
4546
val noiseWBucket: Int,
@@ -52,7 +53,8 @@ class SherpaTtsRuntime private constructor(
5253

5354
data class SynthesisOutput(val sampleRate: Int, val samples: FloatArray)
5455

55-
fun sampleRateOf(voiceId: String): Int = engine(voiceId, 1f).sampleRate()
56+
fun sampleRateOf(voiceId: String, languageHint: String? = null): Int =
57+
engine(voiceId, 1f, languageHint = languageHint).sampleRate()
5658

5759
fun synthesize(
5860
voiceId: String,
@@ -63,8 +65,9 @@ class SherpaTtsRuntime private constructor(
6365
lengthScale: Float = 1.0f,
6466
noiseScale: Float = 0.667f,
6567
noiseScaleW: Float = 0.8f,
68+
languageHint: String? = null,
6669
): SynthesisOutput {
67-
val tts = engine(voiceId, lengthScale, noiseScale, noiseScaleW)
70+
val tts = engine(voiceId, lengthScale, noiseScale, noiseScaleW, text, languageHint)
6871
// Clamp the speaker id to the model's actual range before
6972
// crossing the JNI boundary. Kokoro 1.1 ships ~50–100 named
7073
// speakers, but the catalog occasionally over-counts (placeholder
@@ -129,9 +132,10 @@ class SherpaTtsRuntime private constructor(
129132
lengthScale: Float = 1.0f,
130133
noiseScale: Float = 0.667f,
131134
noiseScaleW: Float = 0.8f,
135+
languageHint: String? = null,
132136
onChunk: (FloatArray) -> Int,
133137
): SynthesisOutput {
134-
val tts = engine(voiceId, lengthScale, noiseScale, noiseScaleW)
138+
val tts = engine(voiceId, lengthScale, noiseScale, noiseScaleW, text, languageHint)
135139
val nSpeakers = runCatching { tts.numSpeakers() }.getOrDefault(0)
136140
val safeSid = if (nSpeakers > 0) sid.coerceIn(0, nSpeakers - 1) else 0
137141
val audio = try {
@@ -284,19 +288,40 @@ class SherpaTtsRuntime private constructor(
284288
lengthScale: Float,
285289
noiseScale: Float = 0.667f,
286290
noiseScaleW: Float = 0.8f,
291+
text: String? = null,
292+
languageHint: String? = null,
287293
): OfflineTts = synchronized(this) {
288294
val settings = runCatching { GlobalContext.get().get<SettingsRepository>() }.getOrNull()
289295
val useNnapi = runCatching { runBlocking { settings?.useNnapi?.first() } }.getOrNull() ?: false
290296
val numThreads = runCatching { runBlocking { settings?.synthesisThreads?.first() } }.getOrNull() ?: 2
291297
val maxNumSentences = runCatching { runBlocking { settings?.maxNumSentences?.first() } }.getOrNull() ?: 2
298+
val voice = installedVoiceOf(voiceId)
299+
?: error("Voice $voiceId is not installed; reinstall the bundle.")
300+
val family = runtimeFamily(voice)
301+
val kokoroLanguage = kokoroLanguageFor(
302+
voice = voice,
303+
family = family,
304+
languageHint = languageHint,
305+
text = text,
306+
)
292307

293308
val lengthB = lengthBucket(lengthScale)
294309
val noiseB = noiseBucket(noiseScale)
295310
val noiseWB = noiseBucket(noiseScaleW)
296311

297-
val key = EngineKey(voiceId, lengthB, noiseB, noiseWB, useNnapi, numThreads, maxNumSentences)
312+
val key = EngineKey(voiceId, kokoroLanguage, lengthB, noiseB, noiseWB, useNnapi, numThreads, maxNumSentences)
298313
loaded[key]?.let { return@synchronized it }
299-
val tts = buildEngine(voiceId, lengthScale, noiseScale, noiseScaleW, useNnapi, numThreads, maxNumSentences)
314+
val tts = buildEngine(
315+
voice = voice,
316+
family = family,
317+
kokoroLanguage = kokoroLanguage,
318+
lengthScale = lengthScale,
319+
noiseScale = noiseScale,
320+
noiseScaleW = noiseScaleW,
321+
useNnapi = useNnapi,
322+
numThreads = numThreads,
323+
maxNumSentences = maxNumSentences,
324+
)
300325
loaded[key] = tts
301326
evictIfNeeded(voiceId)
302327
tts
@@ -327,37 +352,33 @@ class SherpaTtsRuntime private constructor(
327352
(noiseScale.coerceIn(0.0f, 2.0f) * 100f).toInt()
328353

329354
private fun buildEngine(
330-
voiceId: String,
355+
voice: InstalledVoice,
356+
family: ModelFamily,
357+
kokoroLanguage: String,
331358
lengthScale: Float,
332359
noiseScale: Float,
333360
noiseScaleW: Float,
334361
useNnapi: Boolean,
335362
numThreads: Int,
336363
maxNumSentences: Int
337364
): OfflineTts {
338-
val voice = installedVoiceOf(voiceId)
339-
val path = voice?.installedPath?.takeIf { it.isNotBlank() }
340-
?: error("Voice $voiceId has no installedPath; reinstall the bundle.")
365+
val path = voice.installedPath.takeIf { it.isNotBlank() }
366+
?: error("Voice ${voice.voiceId} has no installedPath; reinstall the bundle.")
341367
val voiceDir = File(path).also { dir ->
342-
require(dir.isDirectory) { "Voice $voiceId not installed at $dir" }
343-
}
344-
val family = if (voice?.family == ModelFamily.CUSTOM) {
345-
voice.effectiveFamily ?: error(
346-
"Custom voice $voiceId is missing effectiveFamily — re-import the bundle.",
347-
)
348-
} else {
349-
voice?.family ?: ModelFamily.PIPER
368+
require(dir.isDirectory) { "Voice ${voice.voiceId} not installed at $dir" }
350369
}
351-
log.i { "Loading voice $voiceId (family=$family, lengthScale=$lengthScale, noiseScale=$noiseScale, noiseScaleW=$noiseScaleW) from $voiceDir" }
352-
val config = buildConfig(family, voiceDir, lengthScale, noiseScale, noiseScaleW, useNnapi, numThreads, maxNumSentences)
370+
val languageLog = if (kokoroLanguage.isNotBlank()) ", lang=$kokoroLanguage" else ""
371+
log.i { "Loading voice ${voice.voiceId} (family=$family$languageLog, lengthScale=$lengthScale, noiseScale=$noiseScale, noiseScaleW=$noiseScaleW) from $voiceDir" }
372+
val config = buildConfig(family, voiceDir, kokoroLanguage, lengthScale, noiseScale, noiseScaleW, useNnapi, numThreads, maxNumSentences)
353373
val tts = OfflineTts(assetManager = null, config = config)
354-
log.i { "OfflineTts ready for $voiceId (sampleRate=${tts.sampleRate()})" }
374+
log.i { "OfflineTts ready for ${voice.voiceId} (sampleRate=${tts.sampleRate()})" }
355375
return tts
356376
}
357377

358378
private fun buildConfig(
359379
family: ModelFamily,
360380
dir: File,
381+
kokoroLanguage: String,
361382
lengthScale: Float,
362383
noiseScale: Float,
363384
noiseScaleW: Float,
@@ -367,7 +388,7 @@ class SherpaTtsRuntime private constructor(
367388
): OfflineTtsConfig = when (family) {
368389
ModelFamily.PIPER, ModelFamily.VITS -> buildVitsConfig(dir, lengthScale, noiseScale, noiseScaleW, useNnapi, numThreads, maxNumSentences)
369390
ModelFamily.MATCHA -> buildMatchaConfig(dir, lengthScale, useNnapi, numThreads, maxNumSentences)
370-
ModelFamily.KOKORO -> buildKokoroConfig(dir, lengthScale, useNnapi, numThreads, maxNumSentences)
391+
ModelFamily.KOKORO -> buildKokoroConfig(dir, kokoroLanguage, lengthScale, useNnapi, numThreads, maxNumSentences)
371392
ModelFamily.KITTEN -> buildKittenConfig(dir, lengthScale, useNnapi, numThreads, maxNumSentences)
372393
ModelFamily.ZIPVOICE -> buildZipVoiceConfig(dir, useNnapi, numThreads, maxNumSentences)
373394
ModelFamily.POCKET -> buildPocketConfig(dir, useNnapi, numThreads, maxNumSentences)
@@ -416,8 +437,7 @@ class SherpaTtsRuntime private constructor(
416437
maxNumSentences: Int
417438
): OfflineTtsConfig {
418439
val acoustic = resolveModelFile(dir, MATCHA_ACOUSTIC_CANDIDATES)
419-
val vocoder = File(dir, VOCODER_FILE)
420-
check(vocoder.isFile) { "Matcha voice at $dir is missing $VOCODER_FILE" }
440+
val vocoder = resolveVocoderFile(dir, MATCHA_VOCODER_CANDIDATES)
421441
val tokensPath = File(dir, TOKENS_FILE).absolutePath
422442
val dataDir = File(dir, ESPEAK_DIR)
423443
val dataDirPath = if (dataDir.isDirectory) dataDir.absolutePath else ""
@@ -428,7 +448,7 @@ class SherpaTtsRuntime private constructor(
428448
return OfflineTtsConfig(
429449
model = OfflineTtsModelConfig(
430450
matcha = OfflineTtsMatchaModelConfig(
431-
acousticModel = acoustic, vocoder = vocoder.absolutePath,
451+
acousticModel = acoustic, vocoder = vocoder,
432452
lexicon = lexiconPath, tokens = tokensPath,
433453
dataDir = dataDirPath, dictDir = dictDirPath, lengthScale = lengthScale,
434454
),
@@ -441,6 +461,7 @@ class SherpaTtsRuntime private constructor(
441461

442462
private fun buildKokoroConfig(
443463
dir: File,
464+
languageHint: String,
444465
lengthScale: Float,
445466
useNnapi: Boolean,
446467
numThreads: Int,
@@ -453,14 +474,27 @@ class SherpaTtsRuntime private constructor(
453474
val dataDir = File(dir, ESPEAK_DIR)
454475
val dataDirPath = if (dataDir.isDirectory) dataDir.absolutePath else ""
455476
val lexicon = File(dir, LEXICON_FILE)
456-
val lexiconPath = if (lexicon.isFile) lexicon.absolutePath else ""
477+
val multiLexicons = dir.listFiles()
478+
?.filter { it.isFile && it.name.startsWith(KOKORO_LEXICON_PREFIX) && it.name.endsWith(".txt") }
479+
?.sortedBy { it.name }
480+
.orEmpty()
481+
val isMultiLanguage = multiLexicons.isNotEmpty() ||
482+
dir.name.contains("multi-lang", ignoreCase = true) ||
483+
dir.name.contains("v1_", ignoreCase = true)
484+
val lexiconPath = when {
485+
isMultiLanguage && multiLexicons.isNotEmpty() ->
486+
multiLexicons.joinToString(",") { it.absolutePath }
487+
lexicon.isFile -> lexicon.absolutePath
488+
else -> ""
489+
}
490+
val lang = if (isMultiLanguage) languageHint.ifBlank { DEFAULT_KOKORO_LANGUAGE } else ""
457491
val dictDir = File(dir, DICT_DIR)
458492
val dictDirPath = if (dictDir.isDirectory) dictDir.absolutePath else ""
459493
return OfflineTtsConfig(
460494
model = OfflineTtsModelConfig(
461495
kokoro = OfflineTtsKokoroModelConfig(
462496
model = modelPath, voices = voices.absolutePath, tokens = tokensPath,
463-
dataDir = dataDirPath, lexicon = lexiconPath, dictDir = dictDirPath,
497+
dataDir = dataDirPath, lexicon = lexiconPath, lang = lang, dictDir = dictDirPath,
464498
lengthScale = lengthScale,
465499
),
466500
numThreads = numThreads, debug = false,
@@ -594,6 +628,23 @@ class SherpaTtsRuntime private constructor(
594628
error("No .onnx weight file found in $dir (tried $candidates)")
595629
}
596630

631+
private fun resolveVocoderFile(dir: File, candidates: List<String>): String {
632+
candidates.forEach { name ->
633+
val candidate = File(dir, name)
634+
if (candidate.isFile) return candidate.absolutePath
635+
}
636+
val fallback = dir.listFiles()?.firstOrNull { file ->
637+
file.isFile &&
638+
file.extension == "onnx" &&
639+
(file.name.startsWith("vocos-") || file.name.contains("vocoder"))
640+
}
641+
if (fallback != null) {
642+
log.w { "Unknown Matcha vocoder layout at $dir, falling back to ${fallback.name}" }
643+
return fallback.absolutePath
644+
}
645+
error("No Matcha vocoder file found in $dir (tried $candidates)")
646+
}
647+
597648
private fun collectRuleFsts(dir: File): String {
598649
val present = RULE_FST_FILES.mapNotNull { name ->
599650
val f = File(dir, name)
@@ -610,10 +661,45 @@ class SherpaTtsRuntime private constructor(
610661
return snapshot.firstOrNull { it.voiceId == voiceId }
611662
}
612663

664+
private fun runtimeFamily(voice: InstalledVoice): ModelFamily = if (voice.family == ModelFamily.CUSTOM) {
665+
voice.effectiveFamily ?: error(
666+
"Custom voice ${voice.voiceId} is missing effectiveFamily; re-import the bundle.",
667+
)
668+
} else {
669+
voice.family
670+
}
671+
672+
private fun kokoroLanguageFor(
673+
voice: InstalledVoice,
674+
family: ModelFamily,
675+
languageHint: String?,
676+
text: String?,
677+
): String {
678+
if (family != ModelFamily.KOKORO) return ""
679+
val supported = voice.languages.mapNotNull { it.languageHead().takeIf(String::isNotBlank) }
680+
val explicit = languageHint?.languageHead()?.takeIf { it.isNotBlank() }
681+
if (explicit != null && (supported.isEmpty() || explicit in supported)) return explicit
682+
val inferred = text?.let { inferLanguageFromText(it, supported) }
683+
return inferred ?: supported.firstOrNull() ?: explicit.orEmpty()
684+
}
685+
686+
private fun inferLanguageFromText(text: String, supported: List<String>): String? {
687+
fun supports(lang: String): Boolean = supported.isEmpty() || lang in supported
688+
return when {
689+
text.any { it in '\u3040'..'\u30FF' } && supports("ja") -> "ja"
690+
text.any { it in '\uAC00'..'\uD7AF' } && supports("ko") -> "ko"
691+
text.any { it in '\u4E00'..'\u9FFF' } && supports("zh") -> "zh"
692+
text.any { it in 'A'..'Z' || it in 'a'..'z' } && supports("en") -> "en"
693+
else -> null
694+
}
695+
}
696+
697+
private fun String.languageHead(): String =
698+
trim().replace('_', '-').substringBefore('-').lowercase()
699+
613700
companion object {
614701
private const val TOKENS_FILE = "tokens.txt"
615702
private const val LEXICON_FILE = "lexicon.txt"
616-
private const val VOCODER_FILE = "vocos-22khz-univ.onnx"
617703
private const val ESPEAK_DIR = "espeak-ng-data"
618704
private const val DICT_DIR = "dict"
619705
private const val MAX_LOADED_VOICES = 2
@@ -627,9 +713,19 @@ class SherpaTtsRuntime private constructor(
627713

628714
private val VITS_MODEL_CANDIDATES = listOf("model.onnx", "vits-vctk.onnx", "vits-vctk.int8.onnx")
629715
private val MATCHA_ACOUSTIC_CANDIDATES = listOf("model-steps-3.onnx", "model-steps-6.onnx", "acoustic.onnx")
630-
private val KOKORO_MODEL_CANDIDATES = listOf("model.onnx", "kokoro-multi-lang-v1_0.onnx", "kokoro-en-v0_19.onnx")
716+
private val MATCHA_VOCODER_CANDIDATES = listOf("vocos-22khz-univ.onnx", "vocos-16khz-univ.onnx", "vocoder.onnx")
717+
private val KOKORO_MODEL_CANDIDATES = listOf(
718+
"model.onnx",
719+
"kokoro-multi-lang-v1_1.onnx",
720+
"kokoro-multi-lang-v1_0.onnx",
721+
"kokoro-int8-multi-lang-v1_1.onnx",
722+
"kokoro-int8-multi-lang-v1_0.onnx",
723+
"kokoro-en-v0_19.onnx",
724+
)
631725
private val KITTEN_MODEL_CANDIDATES = listOf("model.onnx", "model.int8.onnx")
632726
private const val KOKORO_VOICES_FILE = "voices.bin"
727+
private const val KOKORO_LEXICON_PREFIX = "lexicon-"
728+
private const val DEFAULT_KOKORO_LANGUAGE = "en"
633729
private val ZIPVOICE_ENCODER_CANDIDATES = listOf("encoder.int8.onnx", "encoder.onnx")
634730
private val ZIPVOICE_DECODER_CANDIDATES = listOf("decoder.int8.onnx", "decoder.onnx")
635731
private val ZIPVOICE_VOCODER_CANDIDATES = listOf("vocos_24khz.onnx", "vocos_22khz.onnx", "vocoder.onnx")

0 commit comments

Comments
 (0)