AI Pulse

Qwen3-TTS:28秒录音克隆声音,还能按指令带情绪说话

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,希望这篇文章对你有帮助,也希望它能给你带来一些关于语音嵌入用法的灵感。用它做好事,别做坏事。

阅读原文
📚 相关主题 语音合成实操

订阅 AI Pulse

每天 08:00 · 12:30 · 18:30 · 23:50 更新