【通用视觉】集成 OpenAI CLIP 模型,支持以图搜图、以文搜图、以图搜文等功能

【通用视觉】新增 YOLO 图像分类模型支持
【ASR/TTS】集成 Sherpa TTS(语音合成)与 ASR(语音识别)模块,支持中文、粤语、方言、英文等多种语言
【目标检测】优化视频目标检测功能
This commit is contained in:
dengwenjie
2025-10-24 11:22:39 +08:00
parent 248398a46d
commit 8502b4dc79
68 changed files with 4463 additions and 44 deletions

View File

@@ -6,7 +6,7 @@
<parent>
<groupId>cn.smartjavaai</groupId>
<artifactId>smartjavaai-parent</artifactId>
<version>1.0.25</version>
<version>1.0.26</version>
</parent>
<artifactId>speech</artifactId>
@@ -48,10 +48,16 @@
<artifactId>jave-core</artifactId>
<version>3.5.0</version>
</dependency>
<dependency>
<groupId>io.gitee.dengwenjie</groupId>
<artifactId>sherpa-onnx-java-api</artifactId>
<version>1.12.14</version>
</dependency>
</dependencies>
<version>1.0.25</version>
<version>1.0.26</version>
<name>speech</name>
<description>SmartJavaAI</description>
<url>https://github.com/geekwenjie/SmartJavaAI</url>

View File

@@ -22,4 +22,6 @@ public class AsrModelConfig extends ModelConfig {
* 依赖库目录
*/
private Path libPath;
private String modelName;
}

View File

@@ -8,7 +8,31 @@ public enum AsrModelEnum {
WHISPER,
VOSK;
VOSK,
SHERPA_PARAFORMER,
SHERPA_TRANSDUCER,
SHERPA_WHISPER,
SHERPA_FIREREDASR,
SHERPA_MOONSHINE,
SHERPA_NEMO,
SHERPA_SENSEVOICE,
SHERPA_DOLPHIN,
SHERPA_ZIPFORMERCTC,
SHERPA_WENETCTC,
SHERPA_CANARY,
SHERPA_TELESPEECH;
/**
* 根据名称获取枚举 (忽略大小写和下划线变体)

View File

@@ -0,0 +1,335 @@
package cn.smartjavaai.speech.asr.factory;
import cn.smartjavaai.common.enums.DeviceEnum;
import cn.smartjavaai.common.utils.FileUtils;
import cn.smartjavaai.speech.asr.config.AsrModelConfig;
import cn.smartjavaai.speech.asr.exception.AsrException;
import cn.smartjavaai.speech.tts.config.TtsModelConfig;
import cn.smartjavaai.speech.tts.exception.TtsException;
import com.k2fsa.sherpa.onnx.*;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import java.io.File;
import java.util.List;
/**
* @author dwj
*/
public class SherpaOfflineAsrModelConfigFactory {
public static OfflineDolphinModelConfig createDolphinConfig(AsrModelConfig config) {
if(StringUtils.isBlank(config.getModelName())){
throw new AsrException("modelName is null");
}
String model = config.getModelPath() + File.separator + config.getModelName();
OfflineDolphinModelConfig dolphin = OfflineDolphinModelConfig.builder().setModel(model).build();
return dolphin;
}
public static OfflineFireRedAsrModelConfig createFireRedConfig(AsrModelConfig config) {
List<File> decoderFiles = FileUtils.searchFiles(config.getModelPath(), "decoder", ".onnx",false);
List<File> encoderFiles = FileUtils.searchFiles(config.getModelPath(), "encoder", ".onnx",false);
if (CollectionUtils.isEmpty(decoderFiles)){
throw new AsrException("decoder onnx not found");
}
if (CollectionUtils.isEmpty(encoderFiles)){
throw new AsrException("encoder onnx not found");
}
String encoder = encoderFiles.get(0).getAbsolutePath();
String decoder = decoderFiles.get(0).getAbsolutePath();
OfflineFireRedAsrModelConfig fireRedAsr =
OfflineFireRedAsrModelConfig.builder().setEncoder(encoder).setDecoder(decoder).build();
return fireRedAsr;
}
public static OfflineMoonshineModelConfig createMoonshineConfig(AsrModelConfig config) {
List<File> preprocessorFiles = FileUtils.searchFiles(config.getModelPath(), "preprocess", ".onnx",false);
List<File> uncachedDecoderFiles = FileUtils.searchFiles(config.getModelPath(), "uncached_decode", ".onnx",false);
List<File> cachedDecoderFiles = FileUtils.searchFiles(config.getModelPath(), "cached_decode", ".onnx",false);
List<File> encoderFiles = FileUtils.searchFiles(config.getModelPath(), "encode", ".onnx",false);
if (CollectionUtils.isEmpty(uncachedDecoderFiles)){
throw new AsrException("uncached_decode onnx not found");
}
if (CollectionUtils.isEmpty(cachedDecoderFiles)){
throw new AsrException("cached_decode onnx not found");
}
if (CollectionUtils.isEmpty(encoderFiles)){
throw new AsrException("encoder onnx not found");
}
if (CollectionUtils.isEmpty(preprocessorFiles)){
throw new AsrException("preprocess onnx not found");
}
String encoder = encoderFiles.get(0).getAbsolutePath();
String preprocessor = preprocessorFiles.get(0).getAbsolutePath();
String cachedDecoder = cachedDecoderFiles.get(0).getAbsolutePath();
String uncachedDecoder = uncachedDecoderFiles.get(0).getAbsolutePath();
OfflineMoonshineModelConfig moonshine =
OfflineMoonshineModelConfig.builder()
.setPreprocessor(preprocessor)
.setEncoder(encoder)
.setUncachedDecoder(uncachedDecoder)
.setCachedDecoder(cachedDecoder)
.build();
return moonshine;
}
public static OfflineNemoEncDecCtcModelConfig createNemoConfig(AsrModelConfig config) {
if(StringUtils.isBlank(config.getModelName())){
throw new AsrException("modelName is null");
}
String model = config.getModelPath() + File.separator + config.getModelName();
OfflineNemoEncDecCtcModelConfig modelConfig = OfflineNemoEncDecCtcModelConfig.builder().setModel(model).build();
return modelConfig;
}
public static OfflineSenseVoiceModelConfig createSenseVoiceConfig(AsrModelConfig config) {
if(StringUtils.isBlank(config.getModelName())){
throw new AsrException("modelName is null");
}
String model = config.getModelPath() + File.separator + config.getModelName();
OfflineSenseVoiceModelConfig senseVoice =
OfflineSenseVoiceModelConfig.builder().setModel(model).build();
return senseVoice;
}
public static OfflineTransducerModelConfig createTransducerConfig(AsrModelConfig config) {
List<File> decoderFiles = FileUtils.searchFiles(config.getModelPath(), "decoder", ".onnx",false);
List<File> encoderFiles = FileUtils.searchFiles(config.getModelPath(), "encoder", "int8.onnx",false);
List<File> joinerFiles = FileUtils.searchFiles(config.getModelPath(), "joiner", ".onnx",false);
if (CollectionUtils.isEmpty(decoderFiles)){
throw new AsrException("decoder onnx not found");
}
if (CollectionUtils.isEmpty(encoderFiles)){
throw new AsrException("encoder onnx not found");
}
if (CollectionUtils.isEmpty(joinerFiles)){
throw new AsrException("joiner onnx not found");
}
String encoder = encoderFiles.get(0).getAbsolutePath();
String decoder = decoderFiles.get(0).getAbsolutePath();
String joiner = joinerFiles.get(0).getAbsolutePath();
OfflineTransducerModelConfig transducer =
OfflineTransducerModelConfig.builder()
.setEncoder(encoder)
.setDecoder(decoder)
.setJoiner(joiner)
.build();
return transducer;
}
public static OfflineParaformerModelConfig createParaformerConfig(AsrModelConfig config) {
String model = config.getModelPath() + File.separator + config.getModelName();
OfflineParaformerModelConfig modelConfig = OfflineParaformerModelConfig.builder().setModel(model).build();
return modelConfig;
}
public static OfflineWenetCtcModelConfig createWenetCtcConfig(AsrModelConfig config) {
if(StringUtils.isBlank(config.getModelName())){
throw new AsrException("modelName is null");
}
String model = config.getModelPath() + File.separator + config.getModelName();
OfflineWenetCtcModelConfig wenetCtc =
OfflineWenetCtcModelConfig.builder().setModel(model).build();
return wenetCtc;
}
public static OfflineCanaryModelConfig createCanaryConfig(AsrModelConfig config) {
List<File> decoderFiles = FileUtils.searchFiles(config.getModelPath(), "decoder", ".onnx",false);
List<File> encoderFiles = FileUtils.searchFiles(config.getModelPath(), "encoder", ".onnx",false);
if (CollectionUtils.isEmpty(decoderFiles)){
throw new AsrException("decoder onnx not found");
}
if (CollectionUtils.isEmpty(encoderFiles)){
throw new AsrException("encoder onnx not found");
}
String encoder = encoderFiles.get(0).getAbsolutePath();
String decoder = decoderFiles.get(0).getAbsolutePath();
OfflineCanaryModelConfig canary =
OfflineCanaryModelConfig.builder()
.setEncoder(encoder)
.setDecoder(decoder)
.setSrcLang("en")
.setTgtLang("en")
.setUsePnc(true)
.build();
return canary;
}
public static OfflineWhisperModelConfig createWhisperConfig(AsrModelConfig config) {
//是否使用量化模型
boolean useInt8 = config.getCustomParam("useInt8", Boolean.class, false);
String extension = useInt8 ? "int8.onnx" : "onnx";
List<File> decoderFiles = FileUtils.searchFiles(config.getModelPath(), "decoder", extension,false);
List<File> encoderFiles = FileUtils.searchFiles(config.getModelPath(), "encoder", extension,false);
if (CollectionUtils.isEmpty(decoderFiles)){
throw new AsrException("decoder onnx not found");
}
if (CollectionUtils.isEmpty(encoderFiles)){
throw new AsrException("encoder onnx not found");
}
String encoder = encoderFiles.get(0).getAbsolutePath();
String decoder = decoderFiles.get(0).getAbsolutePath();
OfflineWhisperModelConfig fireRedAsr =
OfflineWhisperModelConfig.builder().setEncoder(encoder).setDecoder(decoder).build();
return fireRedAsr;
}
public static OfflineZipformerCtcModelConfig createZipformerCtcConfig(AsrModelConfig config) {
if(StringUtils.isBlank(config.getModelName())){
throw new AsrException("modelName is null");
}
String model = config.getModelPath() + File.separator + config.getModelName();
OfflineZipformerCtcModelConfig zipformerCtc =
OfflineZipformerCtcModelConfig.builder().setModel(model).build();
return zipformerCtc;
}
/**
* 创建模型配置
* @param config
* @return
*/
public static OfflineRecognizerConfig createConfig(AsrModelConfig config) {
OfflineModelConfig modelConfig = null;
int numThreads = config.getCustomParam("numThreads", Integer.class, 1);
boolean debug = config.getCustomParam("debug", Boolean.class, true);
List<File> tokensFiles = FileUtils.findFilesWithSuffix(new File(config.getModelPath()), "tokens.txt", false);
String tokens = CollectionUtils.isEmpty(tokensFiles) ? "" : tokensFiles.get(0).getAbsolutePath();
String provider = config.getCustomParam("provider", String.class, "cpu");
switch (config.getModelEnum()){
case SHERPA_PARAFORMER:
modelConfig =
OfflineModelConfig.builder()
.setParaformer(createParaformerConfig(config))
.setTokens(tokens)
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_TRANSDUCER:
modelConfig =
OfflineModelConfig.builder()
.setTransducer(createTransducerConfig(config))
.setTokens(tokens)
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_WHISPER:
modelConfig =
OfflineModelConfig.builder()
.setWhisper(createWhisperConfig(config))
.setTokens(tokens)
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_FIREREDASR:
modelConfig =
OfflineModelConfig.builder()
.setFireRedAsr(createFireRedConfig(config))
.setTokens(tokens)
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_MOONSHINE:
numThreads = config.getCustomParam("numThreads", Integer.class, 2);
modelConfig =
OfflineModelConfig.builder()
.setMoonshine(createMoonshineConfig(config))
.setTokens(tokens)
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_NEMO:
modelConfig =
OfflineModelConfig.builder()
.setNemo(createNemoConfig(config))
.setTokens(tokens)
.setNumThreads(numThreads)
.setProvider(provider)
.setDebug(debug)
.build();
break;
case SHERPA_SENSEVOICE:
modelConfig =
OfflineModelConfig.builder()
.setSenseVoice(createSenseVoiceConfig(config))
.setTokens(tokens)
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_DOLPHIN:
modelConfig =
OfflineModelConfig.builder()
.setDolphin(createDolphinConfig(config))
.setTokens(tokens)
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_ZIPFORMERCTC:
modelConfig =
OfflineModelConfig.builder()
.setZipformerCtc(createZipformerCtcConfig(config))
.setTokens(tokens)
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_WENETCTC:
modelConfig =
OfflineModelConfig.builder()
.setWenetCtc(createWenetCtcConfig(config))
.setTokens(tokens)
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_CANARY:
modelConfig =
OfflineModelConfig.builder()
.setCanary(createCanaryConfig(config))
.setTokens(tokens)
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_TELESPEECH:
if(StringUtils.isBlank(config.getModelName())){
throw new AsrException("modelName is null");
}
String model = config.getModelPath() + File.separator + config.getModelName();
modelConfig =
OfflineModelConfig.builder()
.setTeleSpeech(model)
.setTokens(tokens)
.setNumThreads(numThreads)
.setDebug(debug)
.setModelType("telespeech_ctc")
.setProvider(provider)
.build();
break;
}
OfflineRecognizerConfig offlineRecognizerConfig = OfflineRecognizerConfig.builder()
.setOfflineModelConfig(modelConfig)
.setDecodingMethod("greedy_search")
.build();
return offlineRecognizerConfig;
}
}

View File

@@ -4,6 +4,7 @@ import cn.smartjavaai.common.config.Config;
import cn.smartjavaai.speech.asr.config.AsrModelConfig;
import cn.smartjavaai.speech.asr.enums.AsrModelEnum;
import cn.smartjavaai.speech.asr.exception.AsrException;
import cn.smartjavaai.speech.asr.model.SherpaRecognizer;
import cn.smartjavaai.speech.asr.model.SpeechRecognizer;
import cn.smartjavaai.speech.asr.model.VoskRecognizer;
import cn.smartjavaai.speech.asr.model.WhisperRecognizer;
@@ -68,7 +69,7 @@ public class SpeechRecognizerFactory {
throw new AsrException("未配置语音识别模型枚举");
}
return modelMap.computeIfAbsent(config.getModelEnum(), k -> {
return createFaceModel(config);
return createModel(config);
});
}
@@ -77,7 +78,7 @@ public class SpeechRecognizerFactory {
* @param config
* @return
*/
private SpeechRecognizer createFaceModel(AsrModelConfig config) {
private SpeechRecognizer createModel(AsrModelConfig config) {
Class<?> clazz = registry.get(config.getModelEnum());
if(clazz == null){
throw new AsrException("Unsupported model");
@@ -98,6 +99,18 @@ public class SpeechRecognizerFactory {
static {
registerModel(AsrModelEnum.WHISPER, WhisperRecognizer.class);
registerModel(AsrModelEnum.VOSK, VoskRecognizer.class);
registerModel(AsrModelEnum.SHERPA_PARAFORMER, SherpaRecognizer.class);
registerModel(AsrModelEnum.SHERPA_TRANSDUCER, SherpaRecognizer.class);
registerModel(AsrModelEnum.SHERPA_WHISPER, SherpaRecognizer.class);
registerModel(AsrModelEnum.SHERPA_FIREREDASR, SherpaRecognizer.class);
registerModel(AsrModelEnum.SHERPA_MOONSHINE, SherpaRecognizer.class);
registerModel(AsrModelEnum.SHERPA_NEMO, SherpaRecognizer.class);
registerModel(AsrModelEnum.SHERPA_SENSEVOICE, SherpaRecognizer.class);
registerModel(AsrModelEnum.SHERPA_DOLPHIN, SherpaRecognizer.class);
registerModel(AsrModelEnum.SHERPA_ZIPFORMERCTC, SherpaRecognizer.class);
registerModel(AsrModelEnum.SHERPA_WENETCTC, SherpaRecognizer.class);
registerModel(AsrModelEnum.SHERPA_CANARY, SherpaRecognizer.class);
registerModel(AsrModelEnum.SHERPA_TELESPEECH, SherpaRecognizer.class);
log.debug("缓存目录:{}", Config.getCachePath());
}

View File

@@ -0,0 +1,99 @@
package cn.smartjavaai.speech.asr.model;
import ai.djl.modality.audio.Audio;
import cn.smartjavaai.common.entity.R;
import cn.smartjavaai.speech.asr.config.AsrModelConfig;
import cn.smartjavaai.speech.asr.entity.AsrResult;
import cn.smartjavaai.speech.asr.entity.RecParams;
import cn.smartjavaai.speech.asr.factory.SherpaOfflineAsrModelConfigFactory;
import cn.smartjavaai.speech.tts.exception.TtsException;
import cn.smartjavaai.speech.tts.factory.SherpaOfflineTtsModelConfigFactory;
import com.k2fsa.sherpa.onnx.*;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
/**
* @author dwj
*/
@Slf4j
public class SherpaRecognizer implements SpeechRecognizer{
private AsrModelConfig config;
private OfflineRecognizer recognizer;
@Override
public void loadModel(AsrModelConfig config) {
this.config = config;
if(StringUtils.isBlank(config.getModelPath())){
throw new TtsException("modelPath is null");
}
Path testModelPath = Paths.get(config.getModelPath());
if(!testModelPath.toFile().exists()){
throw new TtsException("modelPath does not exist: " + testModelPath.toAbsolutePath());
}
if(Objects.isNull(config.getLibPath())){
throw new TtsException("libPath is null");
}
if(!config.getLibPath().toFile().exists()){
throw new TtsException("libPath does not exist: " + testModelPath.toAbsolutePath());
}
try {
//加载依赖库
System.setProperty("sherpa_onnx.native.path",config.getLibPath().toAbsolutePath().toString());
OfflineRecognizerConfig offlineRecognizerConfig = SherpaOfflineAsrModelConfigFactory.createConfig(config);
recognizer = new OfflineRecognizer(offlineRecognizerConfig);
log.debug("Sherpa tts init success");
} catch (Exception e) {
throw new TtsException(e);
}
}
@Override
public R<AsrResult> recognize(Audio audio) {
if(recognizer == null){
throw new TtsException("模型未初始化");
}
OfflineStream stream = recognizer.createStream();
stream.acceptWaveform(audio.getData(), (int)audio.getSampleRate());
recognizer.decode(stream);
String text = recognizer.getResult(stream).getText();
stream.release();
return R.ok(new AsrResult(text));
}
@Override
public R<AsrResult> recognize(String audioPath) {
if(recognizer == null){
throw new TtsException("模型未初始化");
}
OfflineStream stream = recognizer.createStream();
WaveReader reader = new WaveReader(audioPath);
stream.acceptWaveform(reader.getSamples(), reader.getSampleRate());
recognizer.decode(stream);
String text = recognizer.getResult(stream).getText();
stream.release();
return R.ok(new AsrResult(text));
}
private boolean fromFactory = false;
@Override
public void setFromFactory(boolean fromFactory) {
this.fromFactory = fromFactory;
}
public boolean isFromFactory() {
return fromFactory;
}
@Override
public void close() throws Exception {
if(recognizer != null){
recognizer.release();
}
}
}

View File

@@ -1,5 +1,6 @@
package cn.smartjavaai.speech.asr.model;
import ai.djl.modality.audio.Audio;
import cn.smartjavaai.common.entity.R;
import cn.smartjavaai.speech.asr.config.AsrModelConfig;
import cn.smartjavaai.speech.asr.entity.AsrResult;
@@ -45,6 +46,14 @@ public interface SpeechRecognizer extends AutoCloseable{
throw new UnsupportedOperationException("默认不支持该功能");
}
default R<AsrResult> recognize(Audio audio){
throw new UnsupportedOperationException("默认不支持该功能");
}
default R<AsrResult> recognize(Audio audio, RecParams params){
throw new UnsupportedOperationException("默认不支持该功能");
}
default void setFromFactory(boolean fromFactory){
throw new UnsupportedOperationException("默认不支持该功能");
}

View File

@@ -0,0 +1,27 @@
package cn.smartjavaai.speech.tts.config;
import cn.smartjavaai.common.config.ModelConfig;
import cn.smartjavaai.speech.tts.enums.TtsModelEnum;
import lombok.Data;
import java.nio.file.Path;
/**
* @author dwj
* @date 2025/10/14
*/
@Data
public class TtsModelConfig extends ModelConfig {
private TtsModelEnum modelEnum;
private String modelPath;
private String modelName;
/**
* 依赖库目录
*/
private Path libPath;
}

View File

@@ -0,0 +1,14 @@
package cn.smartjavaai.speech.tts.entity;
import com.k2fsa.sherpa.onnx.OfflineTtsCallback;
import lombok.Data;
/**
* @author dwj
*/
@Data
public class SherpaTtsParams extends TtsParams{
private OfflineTtsCallback callback;
}

View File

@@ -0,0 +1,23 @@
package cn.smartjavaai.speech.tts.entity;
import lombok.Data;
/**
* tts参数
* @author dwj
*/
@Data
public abstract class TtsParams {
/**
* 发音人
*/
private int speakerId;
/**
* 语速
*/
private float speed;
}

View File

@@ -0,0 +1,40 @@
package cn.smartjavaai.speech.tts.enums;
/**
* tts模型枚举
* @author dwj
*/
public enum TtsModelEnum {
SHERPA_VITS("vits"),
SHERPA_MATCHA("matcha"),
SHERPA_KOKORO("kokoro"),
SHERPA_KITTEN("kitten");
TtsModelEnum(String engine) {
this.engine = engine;
}
/**
* 模型引擎
*/
private final String engine;
public String getEngine() {
return engine;
}
/**
* 根据名称获取枚举 (忽略大小写和下划线变体)
*/
public static TtsModelEnum fromName(String name) {
String formatted = name.trim().toUpperCase().replaceAll("[-_]", "");
for (TtsModelEnum model : values()) {
if (model.name().replaceAll("_", "").equals(formatted)) {
return model;
}
}
throw new IllegalArgumentException("未知模型名称: " + name);
}
}

View File

@@ -0,0 +1,29 @@
package cn.smartjavaai.speech.tts.exception;
/**
* tts异常
* @author dwj
*/
public class TtsException extends RuntimeException{
public TtsException() {
super();
}
public TtsException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
public TtsException(String message, Throwable cause) {
super(message, cause);
}
public TtsException(String message) {
super(message);
}
public TtsException(Throwable cause) {
super(cause);
}
}

View File

@@ -0,0 +1,186 @@
package cn.smartjavaai.speech.tts.factory;
import cn.hutool.core.io.FileUtil;
import cn.smartjavaai.common.enums.DeviceEnum;
import cn.smartjavaai.common.utils.FileUtils;
import cn.smartjavaai.speech.tts.exception.TtsException;
import com.k2fsa.sherpa.onnx.*;
import cn.smartjavaai.speech.tts.config.TtsModelConfig;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import java.io.File;
import java.util.List;
/**
* @author dwj
* @date 2025/10/14
*/
public class SherpaOfflineTtsModelConfigFactory {
/**
* 创建Vits模型配置
* @param config
* @return
*/
public static OfflineTtsVitsModelConfig createVitsConfig(TtsModelConfig config) {
List<File> tokensFiles = FileUtils.findFilesByName(new File(config.getModelPath()), "tokens.txt", false);
List<File> lexiconFiles = FileUtils.findFilesByName(new File(config.getModelPath()), "lexicon.txt", false);
List<File> dictFiles = FileUtils.findFilesByName(new File(config.getModelPath()), "dict", false);
List<File> dataDirFiles = FileUtils.findFilesWithSuffix(new File(config.getModelPath()), "-data", false);
String model = config.getModelPath() + File.separator + config.getModelName();;
String tokens = CollectionUtils.isEmpty(tokensFiles) ? "" : tokensFiles.get(0).getAbsolutePath();
String lexicon = CollectionUtils.isEmpty(lexiconFiles) ? "" : lexiconFiles.get(0).getAbsolutePath();
String dictPath = CollectionUtils.isEmpty(dictFiles) ? "" : dictFiles.get(0).getAbsolutePath();
String dataDir = CollectionUtils.isEmpty(dataDirFiles) ? "" : dataDirFiles.get(0).getAbsolutePath();
float lengthScale = config.getCustomParam("lengthScale", Float.class, 1f);
float noiseScale = config.getCustomParam("noiseScale", Float.class, 0.667F);
float noiseScaleW = config.getCustomParam("noiseScaleW", Float.class, 0.8f);
OfflineTtsVitsModelConfig vitsModelConfig =
OfflineTtsVitsModelConfig.builder()
.setModel(model)
.setTokens(tokens)
.setLexicon(lexicon)
.setDictDir(dictPath)
.setDataDir(dataDir)
.setLengthScale(lengthScale)
.setNoiseScale(noiseScale)
.setNoiseScaleW(noiseScaleW)
.build();
return vitsModelConfig;
}
public static OfflineTtsMatchaModelConfig createMatchaConfig(TtsModelConfig config) {
String vocoder = config.getCustomParam("vocoder", String.class);
if(StringUtils.isBlank(vocoder)){
throw new TtsException("vocoder is null");
}
List<File> tokensFiles = FileUtils.findFilesByName(new File(config.getModelPath()), "tokens.txt", false);
List<File> lexiconFiles = FileUtils.findFilesByName(new File(config.getModelPath()), "lexicon.txt", false);
List<File> dictFiles = FileUtils.findFilesByName(new File(config.getModelPath()), "dict", false);
List<File> dataDirFiles = FileUtils.findFilesWithSuffix(new File(config.getModelPath()), "-data", false);
String model = config.getModelPath() + File.separator + config.getModelName();;
String tokens = CollectionUtils.isEmpty(tokensFiles) ? "" : tokensFiles.get(0).getAbsolutePath();
String lexicon = CollectionUtils.isEmpty(lexiconFiles) ? "" : lexiconFiles.get(0).getAbsolutePath();
String dictPath = CollectionUtils.isEmpty(dictFiles) ? "" : dictFiles.get(0).getAbsolutePath();
String dataDir = CollectionUtils.isEmpty(dataDirFiles) ? "" : dataDirFiles.get(0).getAbsolutePath();
float lengthScale = config.getCustomParam("lengthScale", Float.class, 1f);
float noiseScale = config.getCustomParam("noiseScale", Float.class, 1f);
OfflineTtsMatchaModelConfig vitsModelConfig =
OfflineTtsMatchaModelConfig.builder()
.setAcousticModel(model)
.setTokens(tokens)
.setLexicon(lexicon)
.setDictDir(dictPath)
.setDataDir(dataDir)
.setVocoder(vocoder)
.setLengthScale(lengthScale)
.setNoiseScale(noiseScale)
.build();
return vitsModelConfig;
}
public static OfflineTtsKittenModelConfig createKittenConfig(TtsModelConfig config) {
List<File> tokensFiles = FileUtils.findFilesByName(new File(config.getModelPath()), "tokens.txt", false);
List<File> voicesFiles = FileUtils.findFilesByName(new File(config.getModelPath()), "voices.bin", false);
List<File> dataDirFiles = FileUtils.findFilesWithSuffix(new File(config.getModelPath()), "-data", false);
String model = config.getModelPath() + File.separator + config.getModelName();;
String tokens = CollectionUtils.isEmpty(tokensFiles) ? "" : tokensFiles.get(0).getAbsolutePath();
String voices = CollectionUtils.isEmpty(voicesFiles) ? "" : voicesFiles.get(0).getAbsolutePath();
String dataDir = CollectionUtils.isEmpty(dataDirFiles) ? "" : dataDirFiles.get(0).getAbsolutePath();
float lengthScale = config.getCustomParam("lengthScale", Float.class, 1f);
OfflineTtsKittenModelConfig vitsModelConfig =
OfflineTtsKittenModelConfig.builder()
.setModel(model)
.setTokens(tokens)
.setVoices(voices)
.setDataDir(dataDir)
.setLengthScale(lengthScale)
.build();
return vitsModelConfig;
}
public static OfflineTtsKokoroModelConfig createKokoroConfig(TtsModelConfig config) {
List<File> tokensFiles = FileUtils.findFilesByName(new File(config.getModelPath()), "tokens.txt", false);
List<File> lexiconFiles = FileUtils.searchFiles(config.getModelPath(), "lexicon", ".txt",false);
List<File> dictFiles = FileUtils.findFilesByName(new File(config.getModelPath()), "dict", false);
List<File> dataDirFiles = FileUtils.findFilesWithSuffix(new File(config.getModelPath()), "-data", false);
List<File> voicesFiles = FileUtils.findFilesByName(new File(config.getModelPath()), "voices.bin", false);
String model = config.getModelPath() + File.separator + config.getModelName();;
String tokens = CollectionUtils.isEmpty(tokensFiles) ? "" : tokensFiles.get(0).getAbsolutePath();
String lexicon = CollectionUtils.isEmpty(lexiconFiles) ? "" : FileUtils.joinAbsolutePaths(lexiconFiles);
String dictPath = CollectionUtils.isEmpty(dictFiles) ? "" : dictFiles.get(0).getAbsolutePath();
String dataDir = CollectionUtils.isEmpty(dataDirFiles) ? "" : dataDirFiles.get(0).getAbsolutePath();
String voices = CollectionUtils.isEmpty(voicesFiles) ? "" : voicesFiles.get(0).getAbsolutePath();
float lengthScale = config.getCustomParam("lengthScale", Float.class, 1f);
OfflineTtsKokoroModelConfig vitsModelConfig =
OfflineTtsKokoroModelConfig.builder()
.setModel(model)
.setTokens(tokens)
.setLexicon(lexicon)
.setDictDir(dictPath)
.setDataDir(dataDir)
.setVoices(voices)
.setLengthScale(lengthScale)
.build();
return vitsModelConfig;
}
/**
* 创建模型配置
* @param config
* @return
*/
public static OfflineTtsConfig createConfig(TtsModelConfig config) {
OfflineTtsModelConfig modelConfig = null;
int numThreads = config.getCustomParam("numThreads", Integer.class, 1);
boolean debug = config.getCustomParam("debug", Boolean.class, true);
String provider = config.getCustomParam("provider", String.class, "cpu");
String ruleFsts = "";
switch (config.getModelEnum()){
case SHERPA_VITS:
modelConfig =
OfflineTtsModelConfig.builder()
.setVits(createVitsConfig(config))
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_MATCHA:
modelConfig =
OfflineTtsModelConfig.builder()
.setMatcha(createMatchaConfig(config))
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_KITTEN:
modelConfig =
OfflineTtsModelConfig.builder()
.setKitten(createKittenConfig(config))
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
case SHERPA_KOKORO:
numThreads = config.getCustomParam("numThreads", Integer.class, 2);
modelConfig =
OfflineTtsModelConfig.builder()
.setKokoro(createKokoroConfig(config))
.setNumThreads(numThreads)
.setDebug(debug)
.setProvider(provider)
.build();
break;
}
List<File> ruleFstFiles = FileUtils.findFilesWithSuffix(new File(config.getModelPath()), ".fst", false);
ruleFsts = CollectionUtils.isEmpty(ruleFstFiles) ? "" : FileUtils.joinAbsolutePaths(ruleFstFiles);
OfflineTtsConfig offlineTtsConfig =
OfflineTtsConfig.builder().setModel(modelConfig).setRuleFsts(ruleFsts).build();
return offlineTtsConfig;
}
}

View File

@@ -0,0 +1,131 @@
package cn.smartjavaai.speech.tts.factory;
import cn.smartjavaai.common.config.Config;
import cn.smartjavaai.speech.asr.exception.AsrException;
import cn.smartjavaai.speech.asr.model.VoskRecognizer;
import cn.smartjavaai.speech.asr.model.WhisperRecognizer;
import cn.smartjavaai.speech.tts.config.TtsModelConfig;
import cn.smartjavaai.speech.tts.enums.TtsModelEnum;
import cn.smartjavaai.speech.tts.model.SherpaTtsModel;
import cn.smartjavaai.speech.tts.model.TtsModel;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* 语音合成模型工厂
* @author dwj
*/
@Slf4j
public class TtsModelFactory {
// 使用 volatile 和双重检查锁定来确保线程安全的单例模式
private static volatile TtsModelFactory instance;
/**
* 模型缓存
*/
private static final ConcurrentHashMap<TtsModelEnum, TtsModel> modelMap = new ConcurrentHashMap<>();
/**
* 模型注册表
*/
private static final Map<TtsModelEnum, Class<? extends TtsModel>> registry =
new ConcurrentHashMap<>();
public static TtsModelFactory getInstance() {
if (instance == null) {
synchronized (TtsModelFactory.class) {
if (instance == null) {
instance = new TtsModelFactory();
}
}
}
return instance;
}
/**
* 注册模型
* @param expressionModelEnum
* @param clazz
*/
private static void registerModel(TtsModelEnum expressionModelEnum, Class<? extends TtsModel> clazz) {
registry.put(expressionModelEnum, clazz);
}
/**
* 获取模型(通过配置)
* @param config
* @return
*/
public TtsModel getModel(TtsModelConfig config) {
if(Objects.isNull(config) || Objects.isNull(config.getModelEnum())){
throw new AsrException("未配置语音识别模型枚举");
}
return modelMap.computeIfAbsent(config.getModelEnum(), k -> {
return createModel(config);
});
}
/**
* 使用ModelConfig创建模型
* @param config
* @return
*/
private TtsModel createModel(TtsModelConfig config) {
Class<?> clazz = registry.get(config.getModelEnum());
if(clazz == null){
throw new AsrException("Unsupported model");
}
TtsModel model = null;
try {
model = (TtsModel) clazz.newInstance();
} catch (InstantiationException | IllegalAccessException e) {
throw new AsrException(e);
}
model.loadModel(config);
model.setFromFactory(true);
return model;
}
// 初始化默认算法
static {
registerModel(TtsModelEnum.SHERPA_KOKORO, SherpaTtsModel.class);
registerModel(TtsModelEnum.SHERPA_KITTEN, SherpaTtsModel.class);
registerModel(TtsModelEnum.SHERPA_MATCHA, SherpaTtsModel.class);
registerModel(TtsModelEnum.SHERPA_VITS, SherpaTtsModel.class);
log.debug("缓存目录:{}", Config.getCachePath());
}
/**
* 关闭所有已加载的模型
*/
public void closeAll() {
modelMap.values().forEach(model -> {
try {
model.close();
} catch (Exception e) {
e.printStackTrace();
}
});
modelMap.clear();
}
/**
* 移除缓存的模型
* @param modelEnum
*/
public static void removeFromCache(TtsModelEnum modelEnum) {
modelMap.remove(modelEnum);
}
}

View File

@@ -0,0 +1,132 @@
package cn.smartjavaai.speech.tts.model;
import ai.djl.modality.audio.Audio;
import ai.djl.modality.audio.AudioFactory;
import cn.smartjavaai.common.entity.R;
import cn.smartjavaai.common.enums.DeviceEnum;
import cn.smartjavaai.speech.asr.exception.AsrException;
import cn.smartjavaai.speech.asr.pool.WhisperStatePool;
import cn.smartjavaai.speech.tts.config.TtsModelConfig;
import cn.smartjavaai.speech.tts.entity.SherpaTtsParams;
import cn.smartjavaai.speech.tts.entity.TtsParams;
import cn.smartjavaai.speech.tts.exception.TtsException;
import cn.smartjavaai.speech.tts.factory.SherpaOfflineTtsModelConfigFactory;
import com.k2fsa.sherpa.onnx.GeneratedAudio;
import com.k2fsa.sherpa.onnx.OfflineTts;
import com.k2fsa.sherpa.onnx.OfflineTtsCallback;
import com.k2fsa.sherpa.onnx.OfflineTtsConfig;
import io.github.givimad.whisperjni.WhisperJNI;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
/**
* @author dwj
*/
@Slf4j
public class SherpaTtsModel implements TtsModel{
private TtsModelConfig config;
private OfflineTts offlineTts;
@Override
public void loadModel(TtsModelConfig config) {
this.config = config;
if(StringUtils.isBlank(config.getModelPath())){
throw new TtsException("modelPath is null");
}
if(StringUtils.isBlank(config.getModelName())){
throw new TtsException("modelName is null");
}
Path testModelPath = Paths.get(config.getModelPath());
if(!testModelPath.toFile().exists()){
throw new TtsException("modelPath does not exist: " + testModelPath.toAbsolutePath());
}
if(Objects.isNull(config.getLibPath())){
throw new TtsException("libPath is null");
}
if(!config.getLibPath().toFile().exists()){
throw new TtsException("libPath does not exist: " + testModelPath.toAbsolutePath());
}
try {
//加载依赖库
System.setProperty("sherpa_onnx.native.path",config.getLibPath().toAbsolutePath().toString());
OfflineTtsConfig offlineTtsConfig = SherpaOfflineTtsModelConfigFactory.createConfig(config);
offlineTts = new OfflineTts(offlineTtsConfig);
log.debug("Sherpa tts init success");
} catch (Exception e) {
throw new TtsException(e);
}
}
private GeneratedAudio generateCore(String text, TtsParams params) {
if(offlineTts == null){
throw new TtsException("模型未初始化");
}
SherpaTtsParams sherpaTtsParams =null;
if(params == null){
sherpaTtsParams = new SherpaTtsParams();
}else{
if(params instanceof SherpaTtsParams){
sherpaTtsParams = (SherpaTtsParams) params;
}else{
throw new TtsException("params参数类型不是 SherpaTtsParams");
}
}
try {
int sid = 100;
float speed = 1.0f;
if(params != null){
sid = sherpaTtsParams.getSpeakerId() > 0 ? sherpaTtsParams.getSpeakerId() : 100;
speed = sherpaTtsParams.getSpeed() > 0.0f ? sherpaTtsParams.getSpeed() : 1.0f;
}
GeneratedAudio audio = null;
if(sherpaTtsParams.getCallback() != null){
audio = offlineTts.generateWithCallback(text, sid, speed, sherpaTtsParams.getCallback());
}else{
audio = offlineTts.generate(text, sid, speed);
}
float audioDuration = audio.getSamples().length / (float) audio.getSampleRate();
log.debug("-- audio duration: {} seconds", String.format("%.3f", audioDuration));
return audio;
} catch (Exception e) {
throw new TtsException(e);
}
}
@Override
public R<Audio> generate(String text, TtsParams params) {
GeneratedAudio audio = generateCore(text, params);
return R.ok(new Audio(audio.getSamples()));
}
@Override
public void generate(String text, TtsParams params, String savePath) {
GeneratedAudio audio = generateCore(text, params);
audio.save(savePath);
}
private boolean fromFactory = false;
@Override
public void setFromFactory(boolean fromFactory) {
this.fromFactory = fromFactory;
}
public boolean isFromFactory() {
return fromFactory;
}
@Override
public void close() throws Exception {
if(offlineTts != null){
offlineTts.release();
}
}
}

View File

@@ -0,0 +1,32 @@
package cn.smartjavaai.speech.tts.model;
import ai.djl.modality.audio.Audio;
import cn.smartjavaai.common.entity.R;
import cn.smartjavaai.speech.tts.config.TtsModelConfig;
import cn.smartjavaai.speech.tts.entity.TtsParams;
/**
* tts模型
* @author dwj
*/
public interface TtsModel extends AutoCloseable{
/**
* 加载模型
* @param config
*/
void loadModel(TtsModelConfig config);
default R<Audio> generate(String text, TtsParams params){
throw new UnsupportedOperationException("默认不支持该功能");
}
default void generate(String text, TtsParams params, String savePath){
throw new UnsupportedOperationException("默认不支持该功能");
}
default void setFromFactory(boolean fromFactory){
throw new UnsupportedOperationException("默认不支持该功能");
}
}

View File

@@ -1,5 +1,6 @@
package cn.smartjavaai.speech.utils;
import ai.djl.modality.audio.Audio;
import cn.hutool.core.lang.UUID;
import cn.smartjavaai.speech.asr.exception.AsrException;
import ws.schild.jave.Encoder;
@@ -9,6 +10,7 @@ import ws.schild.jave.encode.AudioAttributes;
import ws.schild.jave.encode.EncodingAttributes;
import ws.schild.jave.info.MultimediaInfo;
import javax.sound.sampled.AudioFileFormat;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
@@ -208,5 +210,62 @@ public class AudioUtils {
}
/**
* 将 float[] 音频数据保存为 WAV 文件
*/
public static void saveToWav(float[] floats, AudioFormat format, String savePath) throws IOException {
// 1. 转换为 16-bit PCM
byte[] bytes = floatsToPCM16(floats);
// 2. 使用 ByteArrayInputStream 封装为音频流
try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
AudioInputStream ais = new AudioInputStream(bais, format, floats.length)) {
// 3. 保存到本地文件
AudioSystem.write(ais, AudioFileFormat.Type.WAVE, new File(savePath));
}
}
/**
* 将 float[] 音频数据保存为 WAV 文件
*/
public static void saveToWav(float[] floats, String savePath) throws IOException {
// 1. 转换为 16-bit PCM
byte[] bytes = floatsToPCM16(floats);
// 2. 使用 ByteArrayInputStream 封装为音频流
try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
AudioInputStream ais = new AudioInputStream(bais, getDefaultAudioFormat(), floats.length)) {
// 3. 保存到本地文件
AudioSystem.write(ais, AudioFileFormat.Type.WAVE, new File(savePath));
}
}
public static AudioFormat getDefaultAudioFormat(){
return new AudioFormat(
AudioFormat.Encoding.PCM_SIGNED,
16000,
16,
1,
2,
16000,
false
);
}
/**
* 将 float[] 转为 16bit PCM (little endian)
*/
private static byte[] floatsToPCM16(float[] floats) {
byte[] bytes = new byte[floats.length * 2];
int i = 0;
for (float sample : floats) {
// 裁剪范围 [-1, 1]
sample = Math.max(-1.0f, Math.min(1.0f, sample));
short s = (short) (sample * 32767);
bytes[i++] = (byte) (s & 0xFF);
bytes[i++] = (byte) ((s >> 8) & 0xFF);
}
return bytes;
}
}