Qwen3-TTS:28秒录音克隆声音,还能按指令带情绪说话
最近我和孩子们正在一起开发一款小型地牢爬行(dungeon crawler)游戏,顺便借此机会学点游戏开发的知识。如今 AI 的能力越来越强,我们自然想为游戏加上旁白和配音。但我们不希望它听起来像那种到处都是的标准 AI 语音,于是我调研了一圈,看看市面上有哪些选择。
一番调查之后,我最终锁定了 Qwen3-TTS——它通过语音嵌入(voice embedding)来实现自定义声音。只要稍微发挥一点创意,这些自定义声音能做的东西比最初看起来要多得多。如果你也感兴趣,这篇文章就来分享我们学到的东西,并附上一些示例。你甚至可以直接把文章丢给你的 AI agent,让它照着实现点什么。
Qwen3-TTS 提供的模型
Qwen3 并不是市面上唯一的 TTS 模型,甚至不是我最初的选择——我们一开始用的是 Kokoro。但 Qwen3 支持声音克隆(voice cloning),而我们希望游戏里的声音能更有独特性。不过实际操作起来会稍微复杂一些。Qwen3 共提供了三种模型:
1. 1.7B-Base(以及 0.6B 版本):从几秒钟的参考音频中克隆声音
2. 1.7B-CustomVoice:内置九个说话人(speaker),并支持指令控制
3. 1.7B-VoiceDesign:根据一段文字描述凭空创造一种声音
Base 模型既可以在上下文(in-context)模式下克隆声音,也可以仅凭说话人嵌入(speaker embedding)来克隆,但目前没有官方文档说明如何在克隆声音的同时用指令控制它。也就是说,和 CustomVoice、VoiceDesign 这两个模型不同,你无法对克隆出来的 Base 模型说“用悲伤的语气说话”或“小声耳语”。
目前官方支持的自定义声音方案有两种:用单一说话人的数据微调(fine-tune)Base 模型,或者先用 VoiceDesign 生成一段音频,再用 Base 把它克隆下来。这其实不算太糟,但问题在于:指令控制能力没有了。
动手试一试
为了写这篇文章,我从自己在 PyAI Conf 大会的演讲录音中截取了 28 秒,在两次停顿之间剪切,确保音频以完整的句子开始和结束,然后用 Whisper 把这段音频转写成了文本。
转写文本实际上只有在“烘焙”(baking,见下文)时才必须用到;但如果想拿克隆声音和已有声音做对比,让模型说出和原录音一模一样的内容会非常有用。
顺便提一句:会议演讲的录音并不理想,但照样能用。这也提醒我们,你并不需要一段特别干净的录音(也不需要很长),就能得到一个还算有说服力的声音克隆。尤其是当这段声音通过低质量的电话听筒传出来时,想骗过你奶奶一点都不难。
最先可以尝试的事情,是把这段录音和另一个声音混合。比如你可以用 VoiceDesign 根据一段文字描述凭空造出一个声音。这篇文章中用到的设计声音,就是用 Qwen 官方 Python 包 qwen-tts(pip install qwen-tts,我用的是 0.1.1 版本)创建的——本文后面的代码也都会用到这个包:
import torch
from qwen_tts import Qwen3TTSModel
kw = dict(device_map="mps", dtype=torch.bfloat16, attn_implementation="sdpa")
design = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign", **kw)
wavs, sr = design.generate_voice_design(
text=open("voices/transcript.txt").read(), language="English",
instruct="A man in his forties with a warm, natural, medium-low voice, speaking "
"clearly and calmly at an easy conversational pace, like someone reading "
"aloud to a friend.",
)
你可以听听这两个声音各自的表现,尤其是当它们混合在一起时:
加载语音示例……
声音嵌入
当 Base 模型仅凭嵌入来克隆声音时,它从录音中提取的是一个说话人嵌入——本质上是由一个训练好的说话人编码器(speaker encoder)输出的 2048 个数字。同一个声音会得到相似的数字,不同的声音则会得到不同的数字。顺便说一句,这也是一种很方便的说话人检测方法:虽然它并不完美,但如果你对一条音轨做点基础的切片处理,它就能很好地区分里面的两个说话人(下面会详细讲)。
因为嵌入本质上只是一个向量,所以你可以对它做算术运算。比如,两个嵌入的加权平均,就对应一个介于两种声音之间的说话人:
base = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-Base", **kw)
def fingerprint(path):
prompt = base.create_voice_clone_prompt(ref_audio=path, x_vector_only_mode=True)
return prompt[0].ref_spk_embedding
def blend(a, b, t):
a, b = a.float(), b.float()
mixed = (1 - t) * a + t * b
return (mixed / mixed.norm() * ((1 - t) * a.norm() + t * b.norm())).to(torch.bfloat16)
armin, designed = fingerprint("voices/armin.flac"), fingerprint("voices/designed.flac")
这里的 armin 对应作者本人声音的指纹(fingerprint),designed 则是上面 VoiceDesign 设计出的声音的指纹。
下面是同一句话在 11 种混合比例下的效果:
加载语音示例……
注意,混合后的声音很快就不再那么像我了,但仍然保留了一些我的特征。而且无论如何,它最终都能生成一个独一无二的声音。
我还发现,当两个声音的语谱图(spectrogram)比较相似时(比如录音条件接近),混合效果会更好。在这段录音里你可以听出来,领夹麦克风(lapel mic)拾取的声音比设计出来的声音稍微“空”了一点。
如果你喜欢某个混合出来的声音,可以把它“烘焙”回一个新的录音文件,之后直接作为参考音频使用:
import soundfile as sf
mixed = blend(armin, designed, 0.5)
audio, rate = say(open("voices/transcript.txt").read(), mixed) # say() 函数在下方会展示
sf.write("voices/final.flac", audio, rate)
final = fingerprint("voices/final.flac")
烘焙后的声音不会和混合结果完全一致,但如果你愿意,后续处理会方便很多——它就只是一个单独的声音参考文件而已。
用嵌入做说话人识别
那么,用这些嵌入来做说话人识别,效果到底如何?我拿了一集 State of Agentic Coding(我和 Ben 录制的播客),把音频切成每 2 秒一个窗口、窗口之间间隔 1 秒,然后把每个窗口都和我从大会演讲中提取的嵌入做对比。
结果有点出人意料(但也许也没那么意外):用大会演讲片段作为参考时,Qwen3 完全无法识别出我。但如果改用节目开头我做自我介绍的那 20 秒作为参考,它能正确识别 98% 的剩余片段。
另一方面,用专门为说话人验证(speaker verification)训练的模型,却能成功地从大会演讲片段中认出我。比如 ECAPA 模型,能在 97% 的窗口里从会议片段中找到我。
下面是这期播客前 20 分钟的音频,用不同方法按说话人分类的结果:
加载语音示例……
带指令的克隆声音
不过,嵌入对声音克隆来说确实非常有用!但怎么给克隆出来的声音下指令呢?关键在于:Base 和 CustomVoice 操控说话人的方式是一样的——把一个向量插入到说话人的输入序列中。在 CustomVoice 中,这个向量是嵌入表(embedding table)中的一行;而在 Base 中,它是来自说话人编码器的 x-vector(说话人向量),这个编码器只有 Base 模型才有。
两个模型的生成代码都接受 voice_clone_prompt(它是 generate 方法的一个常规参数),而且两个模型的 transformer 权重几乎完全相同。所以,我们可以先用 Base 提取嵌入,然后把它交给 CustomVoice,替代它内置的某个说话人:
cv = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", **kw)
def say(text, xvec, instruct=None, language="English"):
ids = cv._tokenize_texts([cv._build_assistant_text(text)])
told = [cv._tokenize_texts([cv._build_instruct_text(instruct)])[0]
if instruct else None]
voice = {"ref_code": [None], "ref_spk_embedding": [xvec],
"x_vector_only_mode": [True], "icl_mode": [False]}
codes, _ = cv.model.generate(
input_ids=ids, instruct_ids=told, voice_clone_prompt=voice,
languages=[language], non_streaming_mode=True,
**cv._merge_generate_kwargs(temperature=0.7, subtalker_temperature=0.7),
)
wavs, rate = cv.model.speech_tokenizer.decode([{"audio_codes": codes[0]}])
return wavs[0], rate
wav, rate = say("I really thought this would work.", armin,
"Speak in a sad, sorrowful tone, voice low and heavy, on the verge of tears.")
为插槽选择一个说话人、一个模型和一条指令,然后听听效果:
加载语音示例……
指令在克隆声音上确实有效,但可靠性不如内置说话人,而且效果因情绪类型而异。不过说实话,实际效果比我预期的要好得多。对于游戏配音来说,这绝对够用了——你可以拥有真正自定义的声音,而不是别人游戏里也有的那种通用 AI 音色。
更多有趣的向量运算
如果一种声音是 2048 维空间中的一个点,而且混合运算可行,那也许我们还能在这个空间做点其他有趣的数学。最直观的尝试就是用数学来操控性别特征。
为了找到方向,我让 VoiceDesign 分别创建了 14 个男声和 14 个女声。用女声的平均向量减去男声的平均向量,就得到了一条“性别轴”,你可以沿着这条轴移动任意声音:
women = torch.stack([fingerprint(f"design/f{i}.wav") for i in range(14)]).float()
men = torch.stack([fingerprint(f"design/m{i}.wav") for i in range(14)]).float()
middle = (women.mean(0) + men.mean(0)) / 2
axis = women.mean(0) - men.mean(0)
axis = axis / axis.norm()
def move(xvec, t):
"""把声音移动到性别轴上的 t 位置,保持其他特征不变。"""
x = xvec.float()
return (x + (t - (x - middle) @ axis) * axis).to(torch.bfloat16)
下面是我的声音克隆沿性别轴移动后的效果:
加载语音示例……
好了,就这些。如果你想玩一玩 Qwen3-TTS,希望这篇文章对你有帮助,也希望它能给你带来一些关于语音嵌入用法的灵感。用它做好事,别做坏事。