语音合成系统
本系统旨在将输入文本转换为语音并返回 MP3 格式的音频文件。系统会自动根据输入文本的语言选择对应的语音合成引擎,并对生成的音频进行缓存,避免重复请求,从而降低资源消耗。
1. 系统简介
系统主要包含以下功能与特性:
多语言支持:
- 英文文本: 使用 OpenAi 提供的 TTS 服务进行语音合成。
- 中文文本: 使用火山引擎的 TTS 服务进行语音合成。
缓存机制:
为了避免对相同输入重复请求,系统会对生成的音频文件进行缓存。每次请求首先会检查缓存记录,如果存在对应缓存且文件存在,则直接返回缓存的音频。源码托管:
相关源码托管在 GitHub。
2. 数据库设计
系统采用数据库缓存 TTS 音频文件,表结构设计如下: postgresql
CREATE TABLE uni_tts_cache (
id BIGINT NOT NULL PRIMARY KEY,
input varchar,
md5 varchar,
path varchar,
lang varchar,
voice varchar,
model varchar,
provider varchar,
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0,
tenant_id BIGINT NOT NULL DEFAULT 0
);
create index uni_tts_cache_md5 on uni_tts_cache(md5);
sqlliste
CREATE TABLE IF NOT EXISTS uni_tts_cache (
id INTEGER PRIMARY KEY,
input TEXT,
md5 TEXT,
path TEXT,
lang TEXT,
voice TEXT,
model TEXT,
provider TEXT,
creator TEXT DEFAULT '',
create_time DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
updater TEXT DEFAULT '',
update_time DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
deleted INTEGER NOT NULL DEFAULT 0,
tenant_id INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS uni_tts_cache_md5 ON uni_tts_cache (md5);
说明:
- 表中使用
md5
字段作为输入文本的唯一标识,便于快速检索缓存记录。 - 通过索引
uni_tts_cache_md5
提高查询效率。 - 其它字段用于记录语音合成过程中的详细信息,如使用的语言、语音模型、服务提供商以及创建、更新时间等。
3. Java 服务端实现
系统服务端采用 Java 编写,主要分为路由处理和语音合成服务两个模块。
3.1 配置数据库和路由
app.properties
jdbc.driverClass=org.sqlite.JDBC
jdbc.url=jdbc:sqlite:java-uni-ai-server.db
jdbc.user=
jdbc.pswd=
jdbc.showSql=true
jdbc.validationQuery=select 1
package com.litongjava.uni.config;
import java.net.URL;
import java.util.List;
import com.litongjava.db.activerecord.Db;
import com.litongjava.tio.utils.hutool.FileUtil;
import com.litongjava.tio.utils.hutool.ResourceUtil;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class DbTables {
public static void init() {
String userTableName = "uni_tts_cache";
boolean created = createTable(userTableName);
if (created) {
log.info("created table:{}", userTableName);
}
}
private static boolean createTable(String userTableName) {
String sql = "SELECT name FROM sqlite_master WHERE type='table' AND name=?";
List<String> tables = Db.queryListString(sql, userTableName);
int size = tables.size();
if (size < 1) {
URL url = ResourceUtil.getResource("sql/" + userTableName + ".sql");
StringBuilder stringBuilder = FileUtil.readURLAsString(url);
int update = Db.update(stringBuilder.toString());
log.info("created:{},{}", userTableName, update);
return true;
}
return false;
}
}
package com.litongjava.uni.config;
import java.io.File;
import com.litongjava.annotation.AConfiguration;
import com.litongjava.annotation.Initialization;
import com.litongjava.jfinal.aop.Aop;
import com.litongjava.tio.boot.admin.config.TioAdminDbConfiguration;
import com.litongjava.tio.boot.server.TioBootServer;
import com.litongjava.tio.http.server.router.HttpRequestRouter;
import com.litongjava.uni.handler.ManimTTSHandler;
@AConfiguration
public class AdminAppConfig {
@Initialization
public void config() {
new File("cache").mkdirs();
// 配置数据库相关
new TioAdminDbConfiguration().config();
DbTables.init();
// 获取 HTTP 请求路由器
TioBootServer server = TioBootServer.me();
HttpRequestRouter r = server.getRequestRouter();
if (r != null) {
ManimTTSHandler manimTTSHandler = Aop.get(ManimTTSHandler.class);
r.add("/api/manim/tts", manimTTSHandler::index);
}
}
}
3.2 路由处理
负责接收 HTTP 请求,将文本参数传递给 TTS 服务,并将合成后的音频以 audio/mp3
的格式返回给客户端。
package com.litongjava.uni.handler;
import com.litongjava.jfinal.aop.Aop;
import com.litongjava.tio.boot.http.TioRequestContext;
import com.litongjava.tio.http.common.HttpRequest;
import com.litongjava.tio.http.common.HttpResponse;
import com.litongjava.tio.http.server.util.Resps;
import com.litongjava.uni.services.ManimTTSService;
public class ManimTTSHandler {
ManimTTSService manimTTSService = Aop.get(ManimTTSService.class);
public HttpResponse index(HttpRequest request) {
HttpResponse response = TioRequestContext.getResponse();
String input = request.getParam("input");
String platform = request.getParam("platform");
String voice_id = request.getParam("voice_id");
byte[] audio = manimTTSService.tts(input, platform, voice_id);
return Resps.bytesWithContentType(response, audio, "audio/mp3");
}
}
3.3 语音合成服务实现
服务主要逻辑如下:
- 缓存查询: 根据输入文本生成 MD5 值,并在缓存表中查询是否已存在对应音频文件。
- 缓存命中: 如果缓存存在且对应文件存在,则直接读取返回缓存文件内容。
- 缓存未命中: 根据输入文本语言判断使用火山引擎(中文)或 OpenAi(英文)的 TTS 服务进行合成。
- 缓存保存: 将生成的音频文件保存到本地,并记录缓存信息到数据库。
package com.litongjava.uni.services;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import com.litongjava.fishaudio.tts.FishAudioClient;
import com.litongjava.fishaudio.tts.FishAudioTTSRequestVo;
import com.litongjava.minimax.MiniMaxHttpClient;
import com.litongjava.minimax.MiniMaxTTSResponse;
import com.litongjava.minimax.MiniMaxVoice;
import com.litongjava.model.http.response.ResponseVo;
import com.litongjava.openai.tts.OpenAiTTSClient;
import com.litongjava.tio.utils.crypto.Md5Utils;
import com.litongjava.tio.utils.hex.HexUtils;
import com.litongjava.tio.utils.hutool.FileUtil;
import com.litongjava.tio.utils.hutool.StrUtil;
import com.litongjava.tio.utils.lang.ChineseUtils;
import com.litongjava.volcengine.VolceTtsClient;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ManimTTSService {
private static final String CACHE_DIR = "cache";
public byte[] tts(String input, String provider, String voiceId) {
// 1. Determine defaults based on whether text contains Chinese
if (ChineseUtils.containsChinese(input)) {
provider = StrUtil.defaultIfBlank(provider, "minimax");
voiceId = StrUtil.defaultIfBlank(voiceId, MiniMaxVoice.Chinese_Mandarin_Gentleman);
} else {
provider = StrUtil.defaultIfBlank(provider, "minimax");
voiceId = StrUtil.defaultIfBlank(voiceId, "English_magnetic_voiced_man");
}
log.info("TTS request: input='{}', provider='{}', voiceId='{}'", input, provider, voiceId);
// 2. Build a file‐based cache key: md5_provider_voice.mp3
String md5 = Md5Utils.md5Hex(input);
String fileName = md5 + "_" + provider + "_" + voiceId + ".mp3";
Path cachePath = Paths.get(CACHE_DIR, fileName);
// 3. Try reading from cache
if (Files.exists(cachePath)) {
log.info("Cache hit: reading TTS from {}", cachePath);
try {
return Files.readAllBytes(cachePath);
} catch (IOException e) {
log.error("Failed to read cache file '{}', will regenerate", cachePath, e);
// remove corrupted cache file
try { Files.deleteIfExists(cachePath); } catch (IOException ignored) {}
}
}
// 4. Cache miss or read‐error → generate new audio
byte[] audioBytes;
switch (provider.toLowerCase()) {
case "volce":
audioBytes = VolceTtsClient.tts(input);
break;
case "fishaudio":
FishAudioTTSRequestVo vo = new FishAudioTTSRequestVo();
vo.setText(input);
vo.setReference_id(voiceId);
ResponseVo fishResp = FishAudioClient.speech(vo);
if (fishResp.isOk()) {
audioBytes = fishResp.getBodyBytes();
} else {
log.error("FishAudio TTS error: {}", fishResp.getBodyString());
return FileUtil.readBytes(new File("default.mp3"));
}
break;
case "minimax":
MiniMaxTTSResponse minimaxResp = MiniMaxHttpClient.speech(input, voiceId);
String audioHex = minimaxResp.getData().getAudio();
audioBytes = HexUtils.decodeHex(audioHex);
break;
default: // fallback to OpenAI
ResponseVo openAiResp = OpenAiTTSClient.speech(input);
if (openAiResp.isOk()) {
audioBytes = openAiResp.getBodyBytes();
} else {
log.error("OpenAI TTS error: {}", openAiResp.getBodyString());
return FileUtil.readBytes(new File("default.mp3"));
}
break;
}
// 5. Save to cache directory
try {
Files.createDirectories(cachePath.getParent());
Files.write(cachePath, audioBytes);
log.info("Generated new TTS audio and cached at {}", cachePath);
} catch (IOException e) {
log.error("Failed to write cache file '{}'", cachePath, e);
// Not fatal: still return generated audio
}
return audioBytes;
}
}
说明:
- 语言判断: 通过工具类
ChineseUtils.containsChinese(input)
判断文本中是否含有中文字符,从而选择合适的 TTS 服务。 - 缓存处理: 若存在缓存,直接读取对应文件;否则调用 TTS 接口生成音频后写入本地并保存记录。
4. Python 客户端集成
为了方便在 Python 项目中调用语音合成服务,提供了一个自定义的语音合成上下文管理器以及示例场景代码(基于 Manim)。
4.1 自定义 Voiceover 模块
custom_voiceover.py
实现了如下功能:
- 根据输入文本生成缓存文件名(利用 MD5)。
- 若缓存文件存在,则直接读取;否则调用 HTTP 接口获取合成音频,并将返回的音频写入缓存。
- 返回一个包含音频文件路径及音频时长的
CustomVoiceoverTracker
对象,供后续场景调用。
# -*- coding: utf-8 -*-
import os
import numpy as np
import requests
from contextlib import contextmanager
from manim import *
from moviepy import AudioFileClip
import hashlib
CACHE_DIR = "tts_cache"
os.makedirs(CACHE_DIR, exist_ok=True)
class CustomVoiceoverTracker:
def __init__(self, audio_path, duration):
self.audio_path = audio_path
self.duration = duration
def get_cache_filename(text):
text_hash = hashlib.md5(text.encode('utf-8')).hexdigest()
return os.path.join(CACHE_DIR, f"{text_hash}.mp3")
@contextmanager
def custom_voiceover_tts(text, token="123456", base_url="https://uni-ai.fly.dev/api/manim/tts"):
cache_file = get_cache_filename(text)
if os.path.exists(cache_file):
audio_file = cache_file
else:
input_text = requests.utils.quote(text)
url = f"{base_url}?token={token}&input={input_text}"
response = requests.get(url, stream=True)
if response.status_code != 200:
raise Exception(f"TTS 接口错误: {response.status_code} - {response.text}")
with open(cache_file, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
audio_file = cache_file
clip = AudioFileClip(audio_file)
duration = clip.duration
clip.close()
tracker = CustomVoiceoverTracker(audio_file, duration)
try:
yield tracker
finally:
pass # 根据需要决定是否清理缓存
4.2 Manim 场景示例
在 Manim 中使用自定义语音合成模块,将合成的音频与场景动画进行同步播放。
from manim import *
from custom_voiceover import custom_voiceover_tts # 导入自定义 voiceover 模块
class CombinedScene(Scene):
def construct(self):
# 使用自定义 voiceover 上下文管理器
with custom_voiceover_tts("今天天气怎么样") as tracker:
# 将生成的音频添加到场景中播放
self.add_sound(tracker.audio_path)
# 同时展示一段文字,动画时长与旁白音频保持一致
text_obj = Text("今天天天气怎么样", font_size=36)
text_obj.to_edge(DOWN)
self.play(Write(text_obj), run_time=tracker.duration)
self.wait(1)
if __name__ == "__main__":
# 基本配置
config.pixel_height = 1080 # 设置分辨率高
config.pixel_width = 1920 # 设置分辨率宽
config.frame_rate = 30 # 设置帧率
config.output_file = "CombinedScene" # 指定输出文件名
config.media_dir = "05" # 输出目录
scene = CombinedScene()
scene.render()
print("Scene rendering finished.")
说明:
- 利用
custom_voiceover_tts
上下文管理器实现音频文件的获取与缓存处理。 - 通过
AudioFileClip
获取音频时长,确保动画播放与语音长度保持一致。 - Manim 场景中使用
self.add_sound
方法将生成的音频文件添加到场景中。
5. 总结
本文档详细介绍了语音合成系统的整体设计与实现:
- 系统简介: 介绍了语音合成服务如何根据输入文本自动选择合成引擎及缓存策略。
- 数据库设计: 说明了 TTS 缓存表的结构及设计理念。
- Java 服务实现: 展示了基于 Java 的路由处理和语音合成服务实现,重点介绍了如何判断文本语言、调用不同的 TTS 接口以及缓存处理逻辑。
- Python 客户端集成: 通过示例代码展示了如何在 Python 环境下调用语音合成服务,并将合成的音频与 Manim 场景动画进行同步。
通过以上模块的协同工作,系统能够高效地完成文本到语音的转换,并通过缓存机制显著提升性能与资源利用率。该文档可作为进一步扩展和定制语音合成服务的参考资料。