@@ -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