PIXIV: 71888962 @Yuuri
TTS浅谈
TTS(Text To Speech)是语音合成(Speech synthesis)的一个分支,文本转语音(TTS)系统将正常语言文本转换为语音。TTS系统对于提高交互性有帮助,最近技术发展迅猛,TTS项目百花齐放。
从RVC的实时变声和动态语调优化,再到效果优秀的VITS,生成对抗网络(GAN)的加持使其声音比以前自然了很多。随着SO-VITS强大的声调控制能力的实现,优秀数据集加持下的Diff-SVC,虚拟歌姬的声音媲美真人,但也随之带来了一系列问题。在我浅显的认知中,我对语音合成还停留在初音未来那个时代,但是时代变化如此之快。目前TTS大有互相融合的趋势,例如fish-diffusion或者Fishaudio旗下的其他项目等,都有不小的进步。
开源项目如此繁盛,商用发展当然也不落后。看向商业阵营,各大公司都有其绝活。比如Acapela Group专门搞已故名人的TTS,这非常有特色。还有众多公司专注于高质量或情感丰富的TTS、定制声音等。而在商用TTS行业中,Azure TTS可以说是龙头,我们本篇文章也会使用微软的免费EdgeTTS服务作为示例。
这里有一些相关的项目链接:
这里还有两个哔哩哔哩up主,专注于音乐翻唱相关的,也推荐参考:
东洋雪莲也比较有实力,但似乎不公开分享很多技术细节,故仅作介绍。
注意,现在互联网上有非常多的TTS引擎,本文章仅使用较为稳定的Edge-TTS,初衷是用于实际应用。如果你需要完全本地使用TTS或者为了好玩个性化训练声音和特殊模型,本教程不适用,请移步到其他个性化TTS炼丹教程,比如GPT-SO-VITS等等。
Edge TTS
TTS接受文字输入,然后输出音频。对于Edge TTS的Python版本,它实际上是通过网络请求工作的。因此,它可以很好地在边缘计算平台上运行,因为音频合成是在云端计算并返回给你的。我们使用的是Edge TTS Python库,这里还有GitHub的项目网址:edge-tts。
安装Edge TTS
首先安装:
pip install edge-tts
Edge TTS有两种工作方式,一种是使用命令行交互的方式,如果你只想使用命令模式,你可以使用pipx安装它(来自官网的教程):
pipx install edge-tts
命令行模式我们不用,这里一笔带过。
Edge TTS使用
首先创建一个Edgetts.py
文件,然后导入所需要的模块:
# 导入需要的库
import asyncio
import edge_tts
import os
然后初始化TTS引擎。为了获取语言和声音的可选项,可以在命令行窗口输入命令来获取支持的列表:
edge-tts --list-voices
你会发现输出了一大坨列表,这里就不放出来了。选择需要的NAME
然后继续写初始化代码:
# 设置要转化为语音的文本
TEXT = "你好啊,这里是lico!欢迎来到lico的元宇宙!"
# 设置语音的语言和声音,注意这个名字是大小写敏感的
VOICE = "zh-CN-XiaoyiNeural"
# 设置输出文件的路径,文件将保存在脚本所在的文件夹中
OUTPUT_FILE = os.path.join(os.path.dirname(os.path.realpath(__file__)), "test.mp3")
使用edgetts的函数进行语音生成。注意,异步运行:
# 定义主函数
async def _main() -> None:
# 创建一个Communicate对象,用于将文本转化为语音
communicate = edge_tts.Communicate(TEXT, VOICE)
# 将语音保存到文件中
await communicate.save(OUTPUT_FILE)
最后加上主函数,整体的代码如下:
# 导入需要的库
import asyncio
import edge_tts
import os
# 设置要转化为语音的文本
TEXT = "你好啊,这里是lico!欢迎来到lico的元宇宙!"
# 设置语音的语言和声音
VOICE = "zh-CN-XiaoyiNeural"
# 设置输出文件的路径,文件将保存在脚本所在的文件夹中
OUTPUT_FILE = os.path.join(os.path.dirname(os.path.realpath(__file__)), "test.mp3")
# 定义主函数
async def _main() -> None:
# 创建一个Communicate对象,用于将文本转化为语音
communicate = edge_tts.Communicate(TEXT, VOICE)
# 将语音保存到文件中
await communicate.save(OUTPUT_FILE)
# 如果这个脚本是直接运行的,而不是被导入的,那么就运行主函数
if __name__ == "__main__":
asyncio.run(_main())
在命令行运行之后,你就会发现脚本旁边生成了一个test.mp3
,这就是生成的语音,你可以尝试在不社会性死亡的情况下播放或者修改成更社死的文本。
Edge TTS还提供了流式生成的选项,这里不过多叙述,因为流式仅指输出,输入是没办法流式的,因为需要对整个文本进行音调和音素的规划,不过我们还是有机会解决这个问题,后续有机会再写吧。
# 官方的流式生成的示例
async def amain() -> None:
"""Main function"""
communicate = edge_tts.Communicate(TEXT, VOICE)
with open(OUTPUT_FILE, "wb") as file:
async for chunk in communicate.stream():
if chunk["type"] == "audio":
file.write(chunk["data"])
elif chunk["type"] == "WordBoundary":
print(f"WordBoundary: {chunk}")
LLM与TTS的结合
我们接下来尝试给我们上次的代码加上TTS语音输出,不过在此之前,我们需要先把TTS脚本改一下,让它变成一个函数,这样我们需要的时候调用它就好了。
我们想要使用命令行交互,并且能够播放声音。考虑到多平台的兼容性,我们安装一个库pygame
:
pip install pygame
然后我们修改之前的脚本,试试能不能播放:
# 导入需要的库
import asyncio
import edge_tts
import os
import pygame
# 设置要转化为语音的文本
TEXT = "你好啊,这里是lico!欢迎来到lico的元宇宙!"
# 设置语音的语言和声音
VOICE = "zh-CN-XiaoyiNeural"
# 设置输出文件的路径,文件将保存在脚本所在的文件夹中
OUTPUT_FILE = os.path.join(os.path.dirname(os.path.realpath(__file__)), "test.mp3")
# 定义主函数
async def _main() -> None:
# 创建一个Communicate对象,用于将文本转化为语音
communicate = edge_tts.Communicate(TEXT, VOICE)
# 将语音保存到文件中
await communicate.save(OUTPUT_FILE)
print(f"文件已保存到: {OUTPUT_FILE}")
# 检查文件是否存在
if not os.path.exists(OUTPUT_FILE):
print("错误: 文件未生成")
return
# 检查文件大小
if os.path.getsize(OUTPUT_FILE) == 0:
print("错误: 文件大小为0")
return
# 初始化pygame混音器
pygame.mixer.init()
# 加载音频文件
pygame.mixer.music.load(OUTPUT_FILE)
# 播放音频文件
pygame.mixer.music.play()
# 等待音频播放结束
while pygame.mixer.music.get_busy():
await asyncio.sleep(1)
# 如果这个脚本是直接运行的,而不是被导入的,那么就运行主函数
if __name__ == "__main__":
asyncio.run(_main())
如果你成功地运行了代码,TTS的声音会通过默认的扬声器播放出来。由于第一次使用Pygame会有一定的载入时间,因此运行脚本后可能要等几秒才会播放出声音。
现在我们有了音频播放函数了,我们可以把这个函数和我们之前的函数合在一起,达到TTS和文字同时交互的效果,整体的代码看起来是这样:
import asyncio # 导入异步IO模块
import edge_tts # 导入edge_tts模块,用于文本转语音
import os # 导入os模块,用于文件路径操作
import pygame # 导入pygame模块,用于音频播放
from openai import OpenAI # 导入OpenAI模块,用于与OpenAI的API交互
# 初始化OpenAI的聊天模型
chat_model = OpenAI(
# 你需要把这个替换成你的后端的API地址
base_url="https://api.openai.com/v1/",
# 这是用于身份验证的 API Key
api_key="sk-SbmHyhKJHt3378h9dn1145141919810D1Fbcd12d"
)
# 设置语音的语言和声音
VOICE = "zh-CN-XiaoyiNeural"
# 设置输出文件的路径,文件将保存在脚本所在的文件夹中,每一次生成后会覆盖之前的,仅用来测试
OUTPUT_FILE = os.path.join(os.path.dirname(os.path.realpath(__file__)), "test.mp3")
# 设置聊天记录列表
chat_history = []
# 在脚本开头就初始化pygame混音器,避免每次调用都初始化浪费时间
pygame.mixer.init()
# 定义主函数,异步执行
async def _main(text: str = '测试') -> None:
# 创建一个Communicate对象,用于将文本转化为语音
communicate = edge_tts.Communicate(text, VOICE)
# 将语音保存到文件中
await communicate.save(OUTPUT_FILE)
print(f"文件已保存到: {OUTPUT_FILE}")
# 检查文件是否存在
if not os.path.exists(OUTPUT_FILE):
print("错误: 文件未生成")
return
# 检查文件大小
if os.path.getsize(OUTPUT_FILE) == 0:
print("错误: 文件大小为0")
return
# 加载音频文件
pygame.mixer.music.load(OUTPUT_FILE)
# 播放音频文件
pygame.mixer.music.play()
# 等待音频播放结束
while pygame.mixer.music.get_busy():
await asyncio.sleep(0.5)
# 定义一个函数,从语言模型获取响应
def get_response_from_llm(question):
# 打印当前的聊天记录
print(f'Here is the history list: {chat_history}')
# 获取最近的聊天记录窗口
chat_history_window = "\n".join([f"{role}: {content}" for role, content in chat_history[-2*4:-1]])
# 生成聊天记录提示
chat_history_prompt = f"Here is the chat history:\n {chat_history_window}"
# 构建消息列表
message = [
{"role": "system", "content": "You are a catgirl! Output in Chinese."},
{"role": "assistant", "content": chat_history_prompt},
{"role": "user", "content": question},
]
# 打印发送到后端的消息
print(f'Message sent to backend: {message}')
# 调用OpenAI的API获取响应
response = chat_model.chat.completions.create(
model='gpt-4o-mini',
messages=message,
temperature=0.7,
)
# 获取响应内容
response_str = response.choices[0].message.content
return response_str
# 主程序入口
if __name__ == "__main__":
while True:
# 获取用户输入
user_input = input("\n输入问题或者请输入'exit'退出:")
if user_input.lower() == 'exit':
print("再见")
break
# 将用户输入添加到聊天记录
chat_history.append(('human', user_input))
# 获取语言模型的响应
response = get_response_from_llm(user_input)
# 打印响应
print(response)
# 将响应添加到聊天记录
chat_history.append(('ai', response))
# 异步运行主函数,将响应转化为语音并播放
asyncio.run(_main(response))
这次功能合并有几个优化:
-
把pygame初始化移到函数外部,脚本首次执行的时候初始化,避免了每次都在函数内初始化,提高速度。
-
函数增加了一个参数
text
,它的类型为str
(字符串),默认的内容为“测试”,避免由于后端出错没有文字可说的情况导致的报错。 -
几个
print
增加了格式化字符串输出,提高命令行交互的可读性。
这个脚本有几个潜在问题(由于是教程就无所谓修复了,但还是拿出来说一下):
-
生成的文件没有重新命名导致每次音频文件都会覆盖,你可以加一个重命名的操作,这样所有生成的语句都可以回溯。
-
语音生成函数里缺乏阻断机制,也就是说如果你短时间内问了AI两次,上一句话没说完下一句就也会同时开始播放,就像两个人同时在说话,会出现声音重叠的问题。(由于异步的问题)此问题也好解决,开一个缓存区就可以,但这不是入门教程该有的内容,后续进阶可能会聊。
拓展:全流式TTS(FSTTS)与语音合成标记语言(SSML)
全流式TTS是一个概念,它应该叫“Full-Stream Text to Speech”,简单地说,TTS可以同时流式地接受文字的输入和输出,并且达到相对自然而流畅的声音。这个技术的难点在于流式输入,在自然语言里,完整的句子对于语气和语调影响很大,如何在句子还没完整输入的时候就直接能够定下这句话的语气呢?我个人认为这需要LLM和TTS引擎的双方配合,一是LLM能根据训练的参数和内置的情感模型预先输出一个情感标记,用于给接下来要输出音频的句子定下情感基调,另一方面还需要TTS引擎能够接受情感基调并流式生成语音。这需要两个LLM紧密配合且都具有相关功能。另一个实现方法是采用多模态输出。多模态很好地解决了两个引擎的协调问题,但问题就在于,这个配合的过程是不可控的,是黑箱。相同的一句话在不同的场合可能有千百种语气(参考“卧槽”的好几种用法),如果把这个控制过程完全交给多模态模型,最终效果很大程度上取决于这个模型的能力,这就造成上限不高,后期可拓展性欠佳的缺点。考虑到作为赛博老婆,语音识别(ASR),声纹识别 (Voiceprint Recognize) 还有离线唤醒(Offline Wake up)等技术都要与LLM进行互动,我们很难把这么多模块合并成一个超大的多模态模型,同时还有好效果,所以我们暂时使用模块化的结构,不同的功能模块各司其职。
我简单画了一个流程图,大概是这样的系统逻辑:
语音合成标记语言是微软Azure Speech里的一个概念,它是一种基于XML的标记语言,可用于微调文本转语音输出属性,例如音调、发音、语速、音量等。与纯文本输入相比,它可以提供更多的控制权和灵活性。相比较简单的情感基调定义,SSML可以更精细地控制语音的细节,当然对于SSML的生成也有更大的挑战,需要能力较强的LLM完成这样复杂的任务,也需要并发和异步保证交互的流畅性。(希望开源的赶快有类似的,这个太贵了)SSML的开发需要大量优质标记的数据集作为支撑,同时实际生产时为了提高速度,可以使用数据库匹配的方式,预先从已有的类似场景中直接加载类似的配置,降本增效。