Skip to content

语音合成系统 ​

本系统旨在将输入文本转换为语音并返回 MP3 格式的音频文件。系统会自动根据输入文本的语言选择对应的语音合成引擎,并对生成的音频进行缓存,避免重复请求,从而降低资源消耗。

1. 系统简介 ​

系统主要包含以下功能与特性:

  • 多语言支持:

    • 英文文本: 使用 OpenAi 提供的 TTS 服务进行语音合成。
    • 中文文本: 使用火山引擎的 TTS 服务进行语音合成。
  • 缓存机制:
    为了避免对相同输入重复请求,系统会对生成的音频文件进行缓存。每次请求首先会检查缓存记录,如果存在对应缓存且文件存在,则直接返回缓存的音频。

  • 源码托管:
    相关源码托管在 GitHub。


2. 数据库设计 ​

系统采用数据库缓存 TTS 音频文件,表结构设计如下: postgresql

sql
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 sql/uni_tts_cache.sql

sql
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

java
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
java
package nexus.io.uni.config;

import java.net.URL;
import java.util.List;

import nexus.io.db.activerecord.Db;
import nexus.io.tio.utils.hutool.FileUtil;
import nexus.io.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.readString(url);
      int update = Db.update(stringBuilder.toString());
      log.info("created:{},{}", userTableName, update);
      return true;
    }
    return false;
  }
}
java
package nexus.io.uni.config;

import java.io.File;

import nexus.io.annotation.AConfiguration;
import nexus.io.annotation.Initialization;
import nexus.io.jfinal.aop.Aop;
import nexus.io.tio.boot.admin.config.TioAdminDbConfiguration;
import nexus.io.tio.boot.server.TioBootServer;
import nexus.io.tio.http.server.router.HttpRequestRouter;
import nexus.io.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 的格式返回给客户端。

java
package nexus.io.uni.handler;

import nexus.io.jfinal.aop.Aop;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.http.server.util.Resps;
import nexus.io.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.4 API Key ​

VOLC_APP_ID=
VOLC_ACCESS_TOKEN=
FISHAUDIO_API_KEY=
MINIMAX_GROUP_ID=
MINIMAX_API_KEY=

3.5 语音合成服务实现 ​

服务主要逻辑如下:

  1. 缓存查询: 根据输入文本生成 MD5 值,并在缓存表中查询是否已存在对应音频文件。
  2. 缓存命中: 如果缓存存在且对应文件存在,则直接读取返回缓存文件内容。
  3. 缓存未命中: 根据输入文本语言判断使用火山引擎(中文)或 OpenAi(英文)的 TTS 服务进行合成。
  4. 缓存保存: 将生成的音频文件保存到本地,并记录缓存信息到数据库。

数据库版本 ​

java
package nexus.io.uni.consts;

public interface TTSPlatform {
  String volce = "volce";
  String openai = "openai";
  String fishaudio = "fishaudio";
  String minimax = "minimax";
}
java
package nexus.io.uni.consts;

public interface UniTableName {
  String UNI_TTS_CACHE = "uni_tts_cache";
}
java
package nexus.io.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 nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.fishaudio.tts.FishAudioClient;
import nexus.io.fishaudio.tts.FishAudioTTSRequestVo;
import nexus.io.minimax.MiniMaxHttpClient;
import nexus.io.minimax.MiniMaxTTSResponse;
import nexus.io.minimax.MiniMaxVoice;
import nexus.io.model.http.response.ResponseVo;
import nexus.io.openai.tts.OpenAiTTSClient;
import nexus.io.tio.utils.crypto.Md5Utils;
import nexus.io.tio.utils.hex.HexUtils;
import nexus.io.tio.utils.hutool.FileUtil;
import nexus.io.tio.utils.hutool.StrUtil;
import nexus.io.tio.utils.lang.ChineseUtils;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;
import nexus.io.uni.consts.TTSPlatform;
import nexus.io.uni.consts.UniTableName;
import nexus.io.volcengine.VolceTtsClient;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ManimTTSService {

  public byte[] tts(String input, String provider, String voice_id) {
    // 1. 根据输入文本内容判断默认 provider 和 voice_id
    if (ChineseUtils.containsChinese(input)) {
      if (StrUtil.isBlank(provider)) {
        provider = "minimax";
      }
      if (StrUtil.isBlank(voice_id)) {
        voice_id = MiniMaxVoice.Chinese_Mandarin_Gentleman;
      }
    } else {
      if (StrUtil.isBlank(provider)) {
        provider = "minimax";
      }
      if (StrUtil.isBlank(voice_id)) {
        voice_id = "English_magnetic_voiced_man";
      }
    }
    log.info("input: {}, provider: {}, voice_id: {}", input, provider, voice_id);

    // 2. 计算 MD5,并从数据库缓存表里查询是否已有生成记录
    String md5 = Md5Utils.md5Hex(input);
    String sql = String.format("SELECT id, path FROM %s WHERE md5 = ? AND provider = ? AND voice = ?", UniTableName.UNI_TTS_CACHE);
    Row row = Db.findFirst(sql, md5, provider, voice_id);

    // 3. 如果查到了缓存记录,就尝试读取文件
    if (row != null) {
      long cacheId = row.getLong("id");
      String path = row.getStr("path");
      byte[] cached = readCachedTts(path, cacheId);
      if (cached != null) {
        // 命中缓存且成功读取
        return cached;
      }
      // 如果文件不存在或读取出错,readCachedTts 方法内部会删除这条缓存记录,下面继续走 TTS 生成流程
    }

    // 4. 如果缓存无效或不存在,就生成新的音频并写入缓存
    long newId = SnowflakeIdUtils.id();
    String newPath = "cache" + File.separator + newId + ".mp3";
    byte[] bodyBytes;

    if (TTSPlatform.volce.equals(provider)) {
      bodyBytes = VolceTtsClient.tts(input);

    } else if (TTSPlatform.fishaudio.equals(provider)) {
      FishAudioTTSRequestVo vo = new FishAudioTTSRequestVo();
      vo.setText(input);
      vo.setReference_id(voice_id);
      ResponseVo responseVo = FishAudioClient.speech(vo);
      if (responseVo.isOk()) {
        bodyBytes = responseVo.getBodyBytes();
      } else {
        log.error("FishAudio TTS error: {}", responseVo.getBodyString());
        return FileUtil.readBytes(new File("default.mp3"));
      }

    } else if (TTSPlatform.minimax.equals(provider)) {
      MiniMaxTTSResponse speech = MiniMaxHttpClient.speech(input, voice_id);
      String audioHex = speech.getData().getAudio();
      bodyBytes = HexUtils.decodeHex(audioHex);

    } else {
      ResponseVo responseVo = OpenAiTTSClient.speech(input);
      if (responseVo.isOk()) {
        bodyBytes = responseVo.getBodyBytes();
      } else {
        log.error("OpenAI TTS error: {}", responseVo.getBodyString());
        return FileUtil.readBytes(new File("default.mp3"));
      }
    }

    // 5. 将新生成的音频写到本地,并存一条缓存记录
    File audioFile = new File(newPath);
    FileUtil.writeBytes(bodyBytes, audioFile);

    Row saveRow = Row.by("id", newId).set("input", input).set("md5", md5).set("path", newPath).set("provider", provider).set("voice", voice_id);
    Db.save(UniTableName.UNI_TTS_CACHE, saveRow);

    return bodyBytes;
  }

  /**
   * 如果 path 有效且文件存在,就尝试读取并返回字节数组;否则删除对应的缓存记录并返回 null。
   */
  private byte[] readCachedTts(String path, long cacheId) {
    if (StrUtil.isBlank(path)) {
      return null;
    }
    Path filePath = Paths.get(path);
    if (Files.exists(filePath)) {
      log.info("Reading cached TTS file at [{}]", path);
      try {
        return Files.readAllBytes(filePath);
      } catch (IOException e) {
        log.error("Failed to read cached TTS file [{}], will delete cache record id={}", path, cacheId, e);
        // 如果读取出错,就删除数据库中对应的缓存记录
        String deleteSql = String.format("DELETE FROM %s WHERE id = ?", UniTableName.UNI_TTS_CACHE);
        Db.delete(deleteSql, cacheId);
        return null;
      }
    } else {
      // 文件实际不存在,直接删除数据库中的缓存记录
      log.warn("Cached file not found at [{}], deleting cache record id={}", path, cacheId);
      String deleteSql = String.format("DELETE FROM %s WHERE id = ?", UniTableName.UNI_TTS_CACHE);
      Db.delete(deleteSql, cacheId);
      return null;
    }
  }
}

无数据库版本 ​

java
package nexus.io.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 nexus.io.fishaudio.tts.FishAudioClient;
import nexus.io.fishaudio.tts.FishAudioTTSRequestVo;
import nexus.io.minimax.MiniMaxHttpClient;
import nexus.io.minimax.MiniMaxTTSResponse;
import nexus.io.minimax.MiniMaxVoice;
import nexus.io.model.http.response.ResponseVo;
import nexus.io.openai.tts.OpenAiTTSClient;
import nexus.io.tio.utils.crypto.Md5Utils;
import nexus.io.tio.utils.hex.HexUtils;
import nexus.io.tio.utils.hutool.FileUtil;
import nexus.io.tio.utils.hutool.StrUtil;
import nexus.io.tio.utils.lang.ChineseUtils;
import nexus.io.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 对象,供后续场景调用。
python
# -*- 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="http://127.0.0.1/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 中使用自定义语音合成模块,将合成的音频与场景动画进行同步播放。

python
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 场景动画进行同步。

通过以上模块的协同工作,系统能够高效地完成文本到语音的转换,并通过缓存机制显著提升性能与资源利用率。该文档可作为进一步扩展和定制语音合成服务的参考资料。


MIT Licensed