跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像AI 生图工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
JavaAIjava算法

Java 接入本地 TTS 模型 Sherpa-ONNX 实现离线文本转语音

在 Java 项目中集成 Sherpa-ONNX 实现离线文本转语音(TTS)的方案。针对断网环境无法使用云端服务的需求,对比了 Piper、PaddleSpeech 等模型后,选定轻量且提供完整 Java API 的 Sherpa-ONNX。教程涵盖核心 JAR 包下载、Kokoro 多语言模型配置、音频格式转换工具类编写及 Spring Boot 接口实现。通过补充官方示例缺失的字典目录配置,解决了加载失败问题,并提供了完整的 Maven 依赖配置与前端测试页面,实现了高质量的本地化语音合成。

NodeJser发布于 2026/3/28更新于 2026/7/1454 浏览
Java 接入本地 TTS 模型 Sherpa-ONNX 实现离线文本转语音

选择合适的 TTS 大模型

最近负责的一项业务中,需要实现'文字实时转语音'的功能。一开始使用的是阿里云智能语音合成服务,API 简单易用,接入后很快就能跑通。

然而,随着需求推进,业务要求在断网环境中也必须正常使用。一些业务现场无法连接外网,而云端 TTS 显然无法满足此要求。

于是开始调研可离线部署的 TTS 大模型。测试了多个可本地运行的模型,包括:

  • Piper
  • 百度飞桨 PaddleSpeech
  • Sherpa-ONNX TTS

最终的选型经历如下:

  • Piper:多次尝试仍无法成功运行,兼容问题较多
  • PaddleSpeech:模型体积较大,部署复杂,与 Java 生态结合不友好
  • Sherpa-ONNX:模型轻量、性能不错、部署简单,尤其是提供完整的 Java API,对 Java 开发者极度友好

综合评估后,Sherpa-ONNX 成为最佳选择。特别是在 Java 项目中,无需额外的 Python 服务,也无需多语言混合部署,直接在 Java 中调用即可实现高质量离线 TTS。

实战教程

官方文档参考: https://k2-fsa.github.io/sherpa/onnx/java-api/non-android-java.html

要跑通 Sherpa-ONNX 的 Java TTS 功能,需要下载两个核心 JAR 包:

  1. 纯 Java 实现的 jar(跨平台通用)
  2. 包含 C++ 底层 JNI 的 jar(按平台区分,如 win-x64、linux-x64 等)

选用的是 v1.12.10 版本,也可以根据需要选择更新版本。

下载好 JAR 后,就可以参考官网示例代码: https://github.com/k2-fsa/sherpa-onnx/blob/master/java-api-examples/NonStreamingTtsKokoroZhEn.java

这里需要特别注意一点:

官方示例中缺少一个必要的配置项:

.setDictDir(dictDir)

由于模型使用字典文件,因此必须手动补上,否则会出现加载失败的问题。

选择的预训练模型是:

kokoro-multi-lang-v1_0

模型介绍与下载地址可参考: https://k2-fsa.github.io/sherpa/onnx/tts/all/Chinese-English/kokoro-multi-lang-v1_0.html

下载预训练模型后,准备工作就完成了。接下来即可运行示例代码,实现离线文本转语音。

核心代码

项目结构

下面贴出基于官方示例改造后的核心代码:

AudioProcessingUtil
package com.example.demo;

import com.k2fsa.sherpa.onnx.GeneratedAudio;
import com.k2fsa.sherpa.onnx.OfflineTts;
import javax.sound.sampled.*;
import java.io.File;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class AudioProcessingUtil {
    private static final String OUTPUT_DIR = "./tts-output/";

    static {
        // 确保输出目录存在
        File dir = new File(OUTPUT_DIR);
        if (!dir.exists()) {
            dir.mkdirs();
        }
    }

    /**
     * 生成 TTS 音频并转换格式
     *
     * @param tts       TTS 引擎实例
     * @param text      要转换的文本
     * @param speakerId 说话人 ID
     * @param speed     语速
     * @return 转换后的音频文件路径
     * @throws Exception 处理过程中可能抛出的异常
     */
    public static String generateAndConvertAudio(OfflineTts tts, String text, int speakerId, float speed) throws Exception {
        // 生成文本的 MD5 作为文件名
        String textMd5 = md5(text);
        // 生成音频
        long start = System.currentTimeMillis();
        GeneratedAudio audio = tts.generate(text, speakerId, speed);
        long stop = System.currentTimeMillis();
        System.out.printf("-- elapsed : %.3f seconds\n", (stop - start) / 1000.0f);

        // 保存为临时 WAV 文件
        String tempWaveFilename = OUTPUT_DIR + textMd5 + ".wav";
        audio.save(tempWaveFilename);

        // 转换音频格式为 48kHz 双声道
        File convertedFile = convertAudioFormat(new File(tempWaveFilename), textMd5);

        // 删除临时文件
        new File(tempWaveFilename).delete();

        if (convertedFile == null) {
            throw new RuntimeException("音频转换失败");
        }
        return convertedFile.getAbsolutePath();
    }

    /**
     * 转换音频格式为 48kHz 双声道
     *
     * @param sourceFile 源文件
     * @param textMd5    文本 MD5 值(用于生成输出文件名)
     * @return 转换后的文件
     */
    private static File convertAudioFormat(File sourceFile, String textMd5) {
        AudioInputStream sourceStream = null;
        AudioInputStream convertedStream = null;
        File outputFile = null;
        try {
            // 读取源音频文件
            sourceStream = AudioSystem.getAudioInputStream(sourceFile);
            AudioFormat sourceFormat = sourceStream.getFormat();
            System.out.println(String.format("源音频格式 - 采样率:%sHz, 声道数:%s, 位深:%sbit",
                    sourceFormat.getSampleRate(), sourceFormat.getChannels(), sourceFormat.getSampleSizeInBits()));

            // 定义目标格式:48000Hz, 双声道 (2), 16bit, signed, little-endian
            AudioFormat targetFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
                    48000.0F, // 采样率:48kHz
                    16,       // 位深:16bit
                    2,        // 声道数:2(立体声)
                    4,        // 帧大小:2 声道 * 2 字节 (16bit) = 4 字节
                    48000.0F, // 帧率等于采样率
                    false);   // little-endian

            // 检查是否支持转换
            if (!AudioSystem.isConversionSupported(targetFormat, sourceFormat)) {
                System.out.println("不支持直接转换,尝试分步转换");
                // 先转换采样率和声道数
                AudioFormat intermediateFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
                        48000.0F, sourceFormat.getSampleSizeInBits(), 2, // 先转为双声道
                        sourceFormat.getSampleSizeInBits() / 8 * 2,
                        48000.0F, sourceFormat.isBigEndian());
                convertedStream = AudioSystem.getAudioInputStream(intermediateFormat, sourceStream);

                // 再转换其他属性
                if (!AudioSystem.isConversionSupported(targetFormat, intermediateFormat)) {
                    System.err.println("无法转换音频格式,使用原始文件");
                    return null;
                }
                convertedStream = AudioSystem.getAudioInputStream(targetFormat, convertedStream);
            } else {
                // 直接转换
                convertedStream = AudioSystem.getAudioInputStream(targetFormat, sourceStream);
            }

            // 生成输出文件
            outputFile = new File(OUTPUT_DIR + textMd5 + "_48k.wav");
            // 写入转换后的音频
            AudioSystem.write(convertedStream, AudioFileFormat.Type.WAVE, outputFile);
            System.out.println(String.format("音频转换成功 - 采样率:48000Hz, 声道数:2, 文件:%s", outputFile.getName()));
            return outputFile;
        } catch (UnsupportedAudioFileException e) {
            System.err.println("不支持的音频文件格式:" + e.getMessage());
            return null;
        } catch (IOException e) {
            System.err.println("音频文件读写失败:" + e.getMessage());
            return null;
        } catch (Exception e) {
            System.err.println("音频格式转换失败:" + e.getMessage());
            return null;
        } finally {
            // 关闭流
            try {
                if (convertedStream != null) convertedStream.close();
                if (sourceStream != null) sourceStream.close();
            } catch (IOException e) {
                System.err.println("关闭音频流失败:" + e.getMessage());
            }
        }
    }

    /**
     * 计算字符串的 MD5 值
     */
    private static String md5(String input) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] messageDigest = md.digest(input.getBytes());
            StringBuilder hexString = new StringBuilder();
            for (byte b : messageDigest) {
                String hex = Integer.toHexString(0xff & b);
                if (hex.length() == 1) {
                    hexString.append('0');
                }
                hexString.append(hex);
            }
            return hexString.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}
DemoApplication
package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Bean
    public WebMvcConfigurer webMvcConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addResourceHandlers(ResourceHandlerRegistry registry) {
                // 确保静态资源能被正确访问
                registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
            }
        };
    }
}
TtsConfig
package com.example.demo;

import com.k2fsa.sherpa.onnx.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TtsConfig {
    @Bean
    public OfflineTts offlineTts() {
        String model = "./kokoro-multi-lang-v1_0/model.onnx";
        String voices = "./kokoro-multi-lang-v1_0/voices.bin";
        String tokens = "./kokoro-multi-lang-v1_0/tokens.txt";
        String dataDir = "./kokoro-multi-lang-v1_0/espeak-ng-data";
        String dictDir = "./kokoro-multi-lang-v1_0/dict";
        String lexicon = "./kokoro-multi-lang-v1_0/lexicon-us-en.txt,./kokoro-multi-lang-v1_0/lexicon-zh.txt";

        OfflineTtsKokoroModelConfig kokoroModelConfig = OfflineTtsKokoroModelConfig.builder()
                .setModel(model)
                .setVoices(voices)
                .setTokens(tokens)
                .setDataDir(dataDir)
                .setLexicon(lexicon)
                .setDictDir(dictDir)
                .build();

        OfflineTtsModelConfig modelConfig = OfflineTtsModelConfig.builder()
                .setKokoro(kokoroModelConfig)
                .setNumThreads(2)
                .setDebug(true)
                .build();

        OfflineTtsConfig config = OfflineTtsConfig.builder().setModel(modelConfig).build();
        return new OfflineTts(config);
    }
}
TtsController
package com.example.demo;

import com.k2fsa.sherpa.onnx.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/tts")
public class TtsController {
    @Autowired
    private OfflineTts tts;

    @PostMapping("/generate")
    public ResponseEntity<TtsResponse> generateTts(@RequestBody TtsRequest request) {
        try {
            String text = request.getText();
            int speakerId = request.getSpeakerId() != null ? request.getSpeakerId() : 47;
            float speed = request.getSpeed() != null ? request.getSpeed() : 1.0f;

            // 使用工具类处理 TTS 生成和转换
            String filePath = AudioProcessingUtil.generateAndConvertAudio(tts, text, speakerId, speed);
            return ResponseEntity.ok(new TtsResponse(filePath, "success"));
        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.internalServerError().body(new TtsResponse(null, "处理失败:" + e.getMessage()));
        }
    }
}
TtsRequest
package com.example.demo;

public class TtsRequest {
    private String text;
    private Integer speakerId;
    private Float speed;

    // Getters and setters
    public String getText() { return text; }
    public void setText(String text) { this.text = text; }
    public Integer getSpeakerId() { return speakerId; }
    public void setSpeakerId(Integer speakerId) { this.speakerId = speakerId; }
    public Float getSpeed() { return speed; }
    public void setSpeed(Float speed) { this.speed = speed; }
}
TtsResponse
package com.example.demo;

public class TtsResponse {
    private String filePath;
    private String message;

    public TtsResponse(String filePath, String message) {
        this.filePath = filePath;
        this.message = message;
    }

    // Getters and setters
    public String getFilePath() { return filePath; }
    public void setFilePath(String filePath) { this.filePath = filePath; }
    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }
}
index.html
<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<title>TTS 服务测试</title>
<style>
body{font-family: Arial, sans-serif;max-width: 800px;margin: 0 auto;padding: 20px;}
.form-group{margin-bottom: 15px;}
label{display: block;margin-bottom: 5px;font-weight: bold;}
input, textarea, button{width: 100%;padding: 8px;box-sizing: border-box;}
textarea{height: 100px;resize: vertical;}
button{background-color: #007bff;color: white;border: none;cursor: pointer;font-size: 16px;}
button:hover{background-color: #0056b3;}
#result{margin-top: 20px;padding: 10px;background-color: #f8f9fa;border: 1px solid #dee2e6;}
.audio-container{margin-top: 10px;}
</style>
</head>
<body>
<h1>TTS 文本转语音服务</h1>
<div class="form-group">
<label for="text">输入文本:</label>
<textarea id="text" placeholder="请输入要转换为语音的文本">运维人员正在操作,请别操作电脑</textarea>
</div>
<div class="form-group">
<label for="speakerId">说话人 ID (0-52):</label>
<input type="number" id="speakerId" min="0" max="52" value="47">
</div>
<div class="form-group">
<label for="speed">语速 (0.5-2.0):</label>
<input type="number" id="speed" min="0.5" max="2.0" step="0.1" value="1.0">
</div>
<button onclick="generateTts()">生成语音</button>
<div id="result"></div>
<script>
async function generateTts(){
    const text = document.getElementById('text').value;
    const speakerId = parseInt(document.getElementById('speakerId').value);
    const speed = parseFloat(document.getElementById('speed').value);
    if(!text){alert('请输入文本');return;}
    const requestData = { text: text, speakerId: speakerId, speed: speed };
    const resultDiv = document.getElementById('result');
    resultDiv.innerHTML = '<p>正在生成语音...</p>';
    try{
        const response = await fetch('/api/tts/generate', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(requestData)});
        const data = await response.json();
        if(response.ok && data.filePath){
            resultDiv.innerHTML = `
                <p>语音生成成功!</p>
                <p>文件路径:${data.filePath}</p>
                <div>
                    <audio controls>
                        <source src="file://${data.filePath}" type="audio/wav">
                        您的浏览器不支持音频播放。
                    </audio>
                </div>
                <p><a href="file://${data.filePath}" download>下载音频文件</a></p>
            `;
        }else{
            resultDiv.innerHTML = `<p>语音生成失败:${data.message ||'未知错误'}</p>`;
        }
    }catch(error){
        console.error('Error:', error);
        resultDiv.innerHTML = '<p>发生错误,请查看控制台。</p>';
    }
}
</script>
</body>
</html>
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.0</version>
        <relativePath/>
    </parent>
    <groupId>com.weixin.wt</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>demo</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <!-- 添加 WebStarter 依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- 添加 Sherpa-onnx 核心依赖 -->
        <dependency>
            <groupId>com.k2fsa.sherpa.onnx</groupId>
            <artifactId>sherpa-onnx</artifactId>
            <version>1.12.10</version>
            <scope>system</scope>
            <systemPath>${project.basedir}/lib/sherpa-onnx-v1.12.10.jar</systemPath>
        </dependency>
        <!-- Windows 平台依赖 -->
        <dependency>
            <groupId>com.k2fsa.sherpa.onnx</groupId>
            <artifactId>sherpa-onnx-native-lib-win</artifactId>
            <version>1.12.10</version>
            <scope>system</scope>
            <systemPath>${project.basedir}/lib/sherpa-onnx-native-lib-win-x64-v1.12.10.jar</systemPath>
        </dependency>
        <!-- Linux 平台依赖 -->
        <dependency>
            <groupId>com.k2fsa.sherpa.onnx</groupId>
            <artifactId>sherpa-onnx-native-lib-linux</artifactId>
            <version>1.12.10</version>
            <scope>system</scope>
            <systemPath>${project.basedir}/lib/sherpa-onnx-native-lib-linux-x64-v1.12.10.jar</systemPath>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                    <includeSystemScope>true</includeSystemScope>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

至此,离线 TTS 模型部署成功。

目录

  1. 选择合适的 TTS 大模型
  2. 实战教程
  3. 核心代码
  4. 项目结构
  5. AudioProcessingUtil
  6. DemoApplication
  7. TtsConfig
  8. TtsController
  9. TtsRequest
  10. TtsResponse
  11. index.html
  12. pom.xml
  • 免费图片AI生成工具免费生成了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 免费图片视频在线生成30秒,将你的创意变成现实开始设计
  • X/Twitter免费视频下载器免登陆无限额度免费视频解析下载了解详情
  • 100+免费在线小游戏爽一把
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • Telegram 机器人 Token 与 ChatID 获取实战指南
  • MATLAB 实现基于强制导向函数法(PFA)的无人机三维路径规划
  • 相干伊辛机在医疗与医疗 AI 领域的应用前景
  • 从前端到 DevOps:各类开发者 AI 工作流工具
  • 基于 Java 和 Leaflet 的湖南省道路长度 WebGIS 系统构建
  • GitHub Copilot 学生认证指南
  • 中国黑客群体的真实收入
  • 全栈分页方案:MyBatisPlus 与 Thymeleaf 整合实现
  • 理解MySQL事务隔离:读未提交、读提交、可重复读和串行化
  • 基于 Jetson Nano 与 YOLOv5s 的无人机道路抛洒物实时检测系统
  • Windows 11 部署 Langchain-Chatchat 本地知识库指南
  • 手势控制电脑方案分析与 Python 实战
  • Whisper v0.2 本地语音转文字工具安装与使用指南
  • Spring Web MVC 入门:从概念到实践
  • 用 Python 构建跨平台 GUI 与 Web 应用:Flet 框架实战
  • Science 封面:AI 大模型 Evo 解码生命密码,基因组学进入新时代
  • 大语言模型技术综述与演进历程
  • 无人机视觉任务常用数据集汇总:检测与分割资源整理
  • AIGC 产品经理面试高频 100 题答案解析
  • 手写 C++ TCP 服务器:自定义协议与粘包处理

相关免费在线工具

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • RSA密钥对生成器

    生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online