1.文本转语音

一灰灰blogQuickMediatts-plugin约 2812 字大约 9 分钟

文本转语音

输入一段文字,输出一段音频,这就是 TTS(Text To Speech)干的事情

在开始之前,先定一个小目标:给插件传入"我喜欢吃葡萄 I like to eat grape"和选定的音色,跑完代码,收获一个能直接播放的 mp3 文件。全文就围绕这个目标展开,先讲思路,再手把手带着跑通。

I. 实现思路

文本转语音这件事,听起来很高大上,其实自己从零搞起(收集语料、训练模型、声学合成)是一个天坑,纯 Java 技术玩家基本不会走这条路。更现实的思路是:调用现成的云服务,把文字发过去,把音频流收回来。

本插件的核心实现来自开源项目 java-ttsopen in new window,走的就是这条路线——借用 Edge 浏览器"大声朗读"功能背后的语音合成接口:

  1. 通过 WebSocket 与 Edge 的语音服务建立长连接;
  2. 先发送"输出格式配置"(告诉服务端要 mp3 还是别的格式),再发送 SSML 报文(语音合成标记语言),带上文本、发音人(voice)、语速、音量等参数;
  3. 服务端以二进制帧持续回传音频数据,客户端把音频字节攒起来,收到结束标记后落盘为音频文件。

quick-media 在这个思路上做了工程化封装:把建连、协议报文、音频流的接收与保存全部收拢,对外只暴露一个链式的 TtsConfig 配置类和一个入口 TtsWrapper,用起来就剩下"传文本、选音色、拿文件"三件事。

II. 依赖引入

插件对应的 maven 坐标为(以工程 pom 为准):

<dependency>
    <groupId>com.github.liuyueyi.media</groupId>
    <artifactId>tts-plugin</artifactId>
    <version>3.1.0</version>
</dependency>

需要说明的是,tts-plugin 是 quick-media 后期新增的插件,可能尚未同步发布到 maven 中央仓库,直接按上面的坐标依赖有可能拉不到包。如果遇到这种情况,可以按下面两种方式处理。

方式一:jitpack 仓库引入

quick-media 项目整体在 jitpack 上有发布记录,可以先添加 jitpack 仓库,再依赖(注意 jitpack 方式的 groupId 与中央仓库不同):

<repositories>
    <repository>
        <id>jitpack.io</id>
        <url>https://jitpack.io</url>
    </repository>
</repositories>

<dependency>
    <groupId>com.github.liuyueyi.quick-media</groupId>
    <artifactId>tts-plugin</artifactId>
    <version>3.1.0</version>
</dependency>

jitpack 的版本号以 https://jitpack.io/#liuyueyi/quick-mediaopen in new window 上实际构建出来的为准,若该 artifact 没有对应的构建版本,请使用方式二。

方式二:本地源码安装

最稳妥的方式是把项目拉下来,进入 plugins/tts-plugin 目录执行 mvn clean install,装进本地仓库之后,上面的中央仓库坐标就能正常引用了。

插件自身的依赖

从 pom 中可以看到,tts-plugin 依赖了这些东西:base-plugin(quick-media 公共基础包,传递依赖自动引入)、okhttp + okio(WebSocket 长连接和音频字节流的承载)、mp3spi(mp3 解码,用于本地播放)、jflac-codec(flac 解码),以及 scope 为 provided 的 slf4j-api 日志门面(需要项目自己带一个日志实现)。

III. 使用姿势

1. 最小示例

整个插件对外最核心的入口是 TtsWrapper,搭配 TtsConfig 链式配置使用:

TtsWrapper.sendTxt(TtsConfig.newConfig()
                .outputFileName("测试")
                .setSsml("我喜欢吃葡萄 I like to eat grape")
                .voice(VoiceEnum.zh_TW_HsiaoChenNeural)
                .over()
);
TtsWrapper.close();

跑完之后,音频文件就躺在默认目录里了。这段代码的调用链拆开来看:

  • TtsConfig.newConfig():创建一个配置实例;outputFileName("测试") 指定输出文件名(不带后缀,后缀由输出格式决定);
  • setSsml(...):传入需要转语音的文本,返回 SsmlConfig 对象,接着可以继续配置音色(voice)、语速(rate)、音量(volume);
  • over():回到 TtsConfig,表示 ssml 配置结束;
  • TtsWrapper.sendTxt(...):发起合成请求,TtsWrapper.close() 用完之后关闭长连接释放资源。

音频默认保存路径与操作系统有关(源码 TtsWrapper 中写死的逻辑):Windows 下是 c://,其他系统是 /tmp。如果想换地方,可以:

// 单例初始化时指定
TtsWrapper.getInstance("/Users/yihui/Desktop").getTts().sendTxt(config);

// 或者直接更新全局默认路径
TtsWrapper.getInstance().updateBaseDir("/Users/yihui/Desktop");

另外 outputFileName 也支持带路径的写法(如 /tmp/music/测试),子目录不存在时会自动创建。

2. 完整参数配置

一个把常用参数都配齐的示例(来自工程测试类 Main#testTts 的扩展版):

TtsConfig config = TtsConfig.newConfig()
        // 输出文件名,不传时会取文本前5个字符 + 时间戳自动生成
        .outputFileName("东北话测试")
        // 输出格式,不传默认 audio_24khz_48kbitrate_mono_mp3
        .outputFormat(OutputFormatEnum.audio_24khz_48kbitrate_mono_mp3)
        // 待转换的文本
        .setSsml("老铁们,双击666")
        // 音色:东北妹子晓北
        .voice(VoiceEnum.zh_CN_liaoning_XiaobeiNeural)
        // 语速:相对值,+50% 表示比默认快一半;也可以传相对数字如 2
        .rate("+50%")
        // 音量:相对值,如 +20%、-10%;绝对值 0.0 ~ 100.0 也支持
        .volume("+20%")
        // 结束 ssml 配置
        .over()
        // 合成完成后直接播放
        .usePlayer();

TtsWrapper.sendTxt(config);
TtsWrapper.close();

上面这段代码会合成一段略带东北口音、语速偏快、声音略大的 mp3,并直接播放出来。

3. 用流的方式保存

默认行为是把音频写到文件。如果希望拿到 InputStream 自己处理(比如直接返回给 http 响应、上传到 OSS),可以通过 saveHook 自定义保存逻辑:

TtsConfig config = TtsConfig.newConfig()
        .saveHook((b, s, t) -> {
            // b: 音频字节流(ByteString)  s: 保存文件名  t: 文件后缀
            InputStream stream = StreamSaveHook.save(b);
            // 拿到流之后想干嘛就干嘛,这里以保存文件为例
            FileWriteUtil.saveFileByStream(stream, "mp3");
            return null;
        })
        .setSsml("飞流直下三千尺 疑是银河落九天")
        .voice(VoiceEnum.zh_CN_liaoning_XiaobeiNeural)
        .over();

TtsWrapper.sendTxt(config);
TtsWrapper.close();

saveHook 是一个函数式接口 OutputSaveHook<T>,入参为音频字节、文件名、后缀,返回值会被原样传递;不设置时默认走 FileSaveHook,保存为本地文件并返回绝对路径。

IV. 参数说明

1. 音色选择(VoiceEnum)

音色是 TTS 里最有意思的参数。VoiceEnum 枚举内置了 300+ 个发音人,全部来自 Edge 语音服务的音色列表,覆盖了几十种语言,几个有代表性的:| 枚举值 | 说明 | | :-: | :-: | | zh_CN_XiaoxiaoNeural | 晓晓,中文女声,默认音色,活泼温暖 | | zh_CN_YunxiNeural | 云希,中文男声,活泼阳光 | | zh_CN_YunjianNeural | 云健,适合影视和体育解说 | | zh_CN_YunyeNeural | 云野,成熟男声,适合有声书 | | zh_CN_liaoning_XiaobeiNeural | 晓北,东北方言女声 | | zh_CN_shaanxi_XiaoniNeural | 晓妮,陕西方言女声 | | zh_HK_HiuMaanNeural | 曉曼,粤语女声 | | zh_TW_HsiaoChenNeural | 曉臻,台湾女声 | | en_US_AriaNeural | Aria,美式英语女声 |

voice() 支持传枚举,也支持直接传字符串(如 .voice("zh-CN-YunxiNeural"),会自动匹配枚举,匹配不到时兜底使用晓晓)。如果想在运行时动态查询有哪些音色,可以借助 LocaleService:

LocaleService service = new LocaleService();
// 按语言前缀查询,如 zh、en、ja
List<VoiceEnum> zhVoices = service.getVoiceByLan("zh");
// 按语言地区枚举查询
List<VoiceEnum> cantonese = service.getVoiceByLocale(LocaleEnum.zh_HK);

完整的音色清单,建议直接翻 VoiceEnum 源码,每个枚举值上都带了中文注释(性别、声音特点、适合场景)。此外插件还内置了 LocaleEnum(语言地区枚举,如 zh_CN、zh_HK、en_US 等上百个),是 LocaleService 查询音色时的检索维度。

2. 语速与音量(rate / volume)

SsmlConfig 上的 rate 和 volume 均支持多种写法(参考 Azure 语音服务的 prosody 规范):

  • 相对百分比:+50%、-50%、50%,表示在默认值基础上变化;
  • 相对数字:rate 传 2 表示两倍速,0.5 表示一半速;
  • 绝对值:volume 支持 0.0 ~ 100.0 的数字,默认 100.0。

两个参数不设置时的默认值为 +0%,即保持原样。

3. 输出格式(OutputFormatEnum)

OutputFormatEnum 定义了 Edge 语音服务支持的全部输出格式,包括 mp3、opus、ogg、pcm、webm、amr 等几十种,常用的有:

枚举值说明
audio_24khz_48kbitrate_mono_mp3默认格式,官方也推荐
audio_16khz_32kbitrate_mono_mp3低码率 mp3,音质一般但文件小
audio_48khz_192kbitrate_mono_mp3高采样率高码率,音质更好
raw_16khz_16bit_mono_pcm裸 pcm 流,方便做二次处理

一点特别提醒:输出文件的后缀是从格式名里解析出来的,比如 audio-24khz-48kbitrate-mono-mp3 解析出 mp3。而内置播放器只认 mp3 和 pcm,选了 opus、ogg 之类的格式就只做文件保存,不要开启 usePlayer(),否则播放环节会报"不支持的音频文件"。

4. 说话风格(TtsStyleEnum)

插件里还预留了一个 TtsStyleEnum,罗列了 Edge 服务支持的说话风格,如 cheerful(愉快)、sad(悲伤)、newscast(新闻播报)、poetry-reading(诗朗诵)、whispering(耳语)等三十多种。

需要说明的是,当前插件组装 SSML 报文时只消费了音色、语速、音量三个参数,style 尚未接入配置链路,这个枚举更多是作为能力参考存在;实在需要的话,可以参考 TtsConfig#toConfig 里的 SSML 模板自行扩展 mstts:express-as 标签。

5. 代理配置

插件与 Edge 语音服务走的是 wss://speech.platform.bing.com 长连接,网络受限的环境下可以配置代理:

// socks 代理,工程测试类 Main 中保留的就是这个示例
TtsConfig.newConfig().setProxy(Proxy.Type.SOCKS, "127.0.0.1", 10808);
// http 代理同理
TtsConfig.newConfig().setProxy(Proxy.Type.HTTP, "127.0.0.1", 8888);

代理不通时合成交互会直接失败,排查问题前可以先确认代理是否可用。

V. 播放器说明

如果配置了 usePlayer(),音频文件保存成功后会调用 Player.autoPlay(path),按文件后缀选择播放器:

  • .mp3 → Mp3Player:借助 mp3spi 的 MpegAudioFileReader 把 mp3 解码为 PCM 音频流,再走播放;
  • .pcm → PcmPlayer:直接把 PCM 数据写入本机音频输出设备(SourceDataLine);
  • 其他后缀 → 抛 TtsException,如上文所说,选 mp3 或 pcm 格式再开启播放最稳。

这个播放能力主要是为本地调试、demo 演示服务的(跑一下就能听到效果);服务端场景一般不需要,直接拿文件或流去对接前端 <audio> 标签。

VI. 机制补充

最后补充几个使用时容易疑惑的点,都来自源码:

  1. TtsWrapper 是单例:全局只持有一条 WebSocket 连接,多个请求复用它,效率更高;但 sendTxt 内部会空转等待上一次合成完成,属于串行模型;
  2. 连接自动回收:超过 5 分钟没有新请求会主动断开长连接,下次调用时再重建;
  3. 文本会做转义:setSsml 里会把文本中的 <、> 替换为《、》,避免破坏 SSML 报文结构;
  4. 必须调用 close():程序退出前记得关闭,否则 okhttp 的线程池和连接池不会被释放。

VII. 小结

一句话总结 tts-plugin 的定位:它把"调云服务合成语音"这件麻烦事封装成了三行代码——建连、协议、音频流都藏在 TtsWrapper + TtsConfig 背后,开箱只需要关心文本、音色和保存方式。

典型应用场景也顺手列几个:有声内容批量生成(文章、小说转音频)、消息通知播报(告警、订单状态语音提醒)、给 video/image 类多媒体产物配音。

项目地址:

更多 quick-media 系列教程,欢迎关注公众号 一灰灰blog

Loading...