Armin Ronacher 博客 · 原文 2026-10-29 · 译文 2026-10-30 · 难度 入门
精读 · 译文(非本站原创)
来自资源池 · Armin Ronacher 博客 ↗ · 原文:Custom Voices With Qwen3-TTS ↗ · 版权归 Armin Ronacher 博客 所有 · 译文为个人学习的非官方翻译,以原文为准;如权利方有疑虑可联系公众号「疯了再说」删除
导读 · Qwen3-TTS 声音克隆的玩法手册:嵌入混合、烘焙、指令克隆与性别轴

为什么精读这篇Armin 给孩子的地牢爬行游戏找旁白,顺手把 Qwen3-TTS 的声音嵌入玩成了线性代数游乐场——两个声音加权平均出新声音、Base 提嵌入+CustomVoice 给指令的跨模型组合 hack、性别轴上的向量平移。教学价值之外还有一条安全注脚:「不需要很干净或很长的录音就能做出以假乱真的声音,电话里骗过你祖母不难」。

  • Qwen3-TTS 三模型分工:1.7B-Base(+0.6B)从几秒参考音频克隆声音(嵌入或上下文两种方式);1.7B-CustomVoice 自带 9 个内置说话人+情感指令;1.7B-VoiceDesign 从文字描述发明声音——痛点:克隆的声音(Base)官方不支持指令,「说悲伤点」无效
  • 说话人嵌入=说话人编码器输出的 2048 个数:同一声音得到相近向量、不同声音相远——副产品是录音的说话人识别(把播客切 2 秒窗、以 1 秒步进比对参考嵌入;用会议录音做参考在 Qwen3 上完全不工作,换用播客开场 20 秒自介绍做参考则 98% 正确;专用说话人验证模型 ECAPA 用会议录音也能 97% 命中)
  • 嵌入算术:加权平均两个嵌入=介于两个声音之间的新声音(blend 函数保范数);混合结果可以「烘焙」回一段新录音当参考文件用(烘焙回来的嵌入与原混合不完全一致但便于管理);两个声音频谱接近(如录音环境相似)时混合效果更好
  • 跨模型组合 hack(本文最妙):Base 与 CustomVoice 操纵说话人的方式相同(一个向量插入说话人输入序列;CustomVoice 用嵌入表的一行,Base 用说话人编码器的 x-vector),transformer 权重几乎一致——用 Base 提取克隆嵌入、交给 CustomVoice 顶替某个内置说话人 → **克隆的声音也能吃指令**(「用悲伤低沉的语气说,几近落泪」);可靠性不如内置说话人但好于预期
  • 性别轴实验:VoiceDesign 各造 14 个男声女声,平均女人减平均男人得到方向向量,任意声音沿该轴平移——给克隆声音做性别调节;声音即 2048 维空间中的点,可以做几何
  • 安全注脚:28 秒会议录音(切在两个停顿之间、首尾成句)即可克隆出电话音质下以假乱真的声音——不需要干净也不需要长的录音
13 段 · 约 8 分钟

With my kids I'm currently building a little dungeon crawler and we're trying to learn a bit about game development along the way. However given that we live in the modern times of increasing capabilities we wanted to get a narrator and voice over. But we also wanted it to not sound like everyone's standard AI voices so I looked into what options there are on the table. After some investigation I narrowed down to using Qwen3-TTS which uses voice embeddings for custom voices. With a bit of creativity you can also do more with those custom voices than appears initially. In case this is of interest to you, this post just covers what we learned and with some examples. You can probably point your agent to it and it can do something with it.

我正和孩子们做一个小的地牢爬行游戏,顺便学点游戏开发。但既然活在能力日新月异的现代,我们想给它加个旁白配音——又不想听起来像人人都有的标准 AI 声音,于是我调查了桌上的选项。一番筛选后锁定 Qwen3-TTS:它用声音嵌入(voice embeddings)做自定义声音,而稍加创意,这些自定义声音能玩出的花样比表面看起来多。如果你有兴趣,这篇就是我们学到的东西加示例——你大概可以把你的 agent 指过来让它自己折腾。

What Qwen Offers · Qwen 提供什么

Qwen3 is not the only TTS model in town, and it wasn't even my first choice. We initially used Kokoro. However Qwen3 supports voice cloning and we wanted to have it feel a bit more unique. In practice it's a bit trickier though. Qwen3 ships three kinds of models: 1.7B-Base (and 0.6B) clones a voice from a few seconds of reference audio. 1.7B-CustomVoice comes with nine built-in speakers and instructions. 1.7B-VoiceDesign invents a voice from a text description. The base model clones either in context or from the speaker embedding alone but there is no documented way to clone a voice and direct it with instructions. That means unlike the custom voice and voice design models you cannot say "speak sad" or "whisper" with a cloned base model. At present, the official options for a custom voice are fine-tuning Base on a single speaker or to make a clip with VoiceDesign and clone it with Base. That is not all that bad, but it means instructions are gone.

Qwen3 不是城里唯一的 TTS 模型,甚至不是我第一选择——我们最初用 Kokoro。但 Qwen3 支持声音克隆,而我们想要更独特一点。实操比想象的绕一点。Qwen3 出三种模型:**1.7B-Base(及 0.6B)**从几秒参考音频克隆声音;**1.7B-CustomVoice** 自带九个内置说话人与指令;**1.7B-VoiceDesign** 从文字描述发明一个声音。Base 模型可以上下文内克隆或只用说话人嵌入克隆,但**没有文档化的办法既克隆声音又用指令引导它**——也就是说,与 CustomVoice 和 VoiceDesign 不同,你不能对克隆的 Base 模型说「说得悲伤」或「耳语」。目前官方的自定义声音路线是:在单说话人数据上微调 Base,或用 VoiceDesign 造一段再拿 Base 克隆——不算糟,但指令没了。

Playing With It · 动手玩

For this blog post I took 28 seconds of me from my talk at PyAI Conf, cut between two pauses so that it starts and ends on a full sentence, and transcribed it with Whisper. The transcript is really only needed for baking but to compare against an already existing voice it's useful to have it say the same thing as the original recording. Note that a conference talk recording is not ideal but it works anyways. Which is also a good reminder that you really don't need a particularly clean recording (or a very long one) to get a halfway convincing version of a person. Particularly when then presented to you on a low quality phone call, it won't be very hard to trick your grandmother.

这篇的素材:我从 PyAI Conf 演讲里取了 28 秒自己的声音,在两个停顿之间下刀(首尾都是完整句),用 Whisper 转了文字。转录其实只在「烘焙」时需要,但要让克隆声音与已有声音对比,让它说原录音同样的话更直观。会议录音并不理想但照样能用——这也提醒我们:**要做出一个半信半疑真假的人声,其实不需要多干净(或多长)的录音**。尤其再经低质量电话一放,骗过你祖母不难。

The first thing you can do is to blend that recording with another voice. For instance you can use VoiceDesign to make up a voice from a description. For this blog post, the designed voice was created like this, with Qwen's own Python package qwen-tts (pip install qwen-tts, I used 0.1.1), which the rest of the code in this post uses too: 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=transcript, 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.")

第一件能做的事:把这段录音与另一个声音混合。比如用 VoiceDesign 从描述造一个声音。本文的设计声音是这样造的(Qwen 自家 Python 包 qwen-tts,pip install qwen-tts,我用 0.1.1,下文代码同):VoiceDesign 模型(Qwen3-TTS-12Hz-1.7B-VoiceDesign,device_map="mps"、dtype=torch.bfloat16,Apple 芯)加载,generate_voice_design 传入转录文本、language="English"、instruct=「四十来岁男人,温暖自然的中低音,清晰从容、不紧不慢的会话语速,像读给朋友听」。

Voice Embeddings · 声音嵌入

What Base takes from a recording when cloning from the embedding alone is a speaker embedding which is basically 2048 numbers out of a speaker encoder that was trained. The same voice gets similar numbers and different voices get different ones. Which btw, is also a convenient way to do speaker detection from recordings if you want to! It's not perfect, but it's quite helpful for a single audio track recording to tell two speakers apart if you do some basic slicing and dicing.

只用嵌入克隆时,Base 从录音里取的东西是**说话人嵌入(speaker embedding)**——本质是训练过的说话人编码器输出的 2048 个数。同一声音得到相近的数、不同声音得到相远的。顺带一提,这也是做录音说话人检测的便捷方式:不完美,但对单音轨录音,稍微切一切分一分,区分两个说话人相当好用。

Because it is just a vector, you can do arithmetic on it. For instance a weighted average of two embeddings is a speaker that is somewhere between two voices: 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"). Here is the same sentence in eleven blends.

因为它只是一个向量,你可以对它做算术。比如两个嵌入的加权平均就是一个介于两个声音之间的说话人:Base 模型(Qwen3-TTS-12Hz-1.7B-Base,dtype=torch.bfloat16)的 create_voice_clone_prompt(ref_audio=path, x_vector_only_mode=True) 拿到 prompt[0] 的 ref_spk_embedding(即 fingerprint 函数);blend(a, b, t) 做 (1-t)·a + t·b 的加权混合并保范数归一(mixed/mixed.norm() 乘回两侧范数的加权和)。同一句话在十一个混合比例下的效果见原文音频。

Note that it stops sounding like me quite quickly, but it retains some of my features. And it for sure ends up with creating a unique voice regardless. I found some better successes when ensuring that the two voices have similar spectograms (eg: similar "recording setups"). In this recording you can hear that one voice picked up by the lapel mic was a bit hollow compared to the designed voice.

注意它很快就不再像我了,但保留了一些我的特征,而且无论如何都会产出一个独一无二的声音。我发现**两个声音频谱相近(比如「录音环境」相似)时混合更成功**——这段录音里能听出领夹麦收的那个声比设计的声音空一点。

If you like a voice that comes out, you can bake it back into a new recording that you can then use as reference: import soundfile as sf; mixed = blend(armin, designed, 0.5); audio, rate = say(open("voices/transcript.txt").read(), mixed); sf.write("voices/final.flac", audio, rate); final = fingerprint("voices/final.flac"). The baked voice does not come back exactly as the blend but it makes handling it easier if you want to. It's just one voice reference file.

喜欢某个混出来的声音,可以把它**烘焙(bake)**回一段新录音当参考用:blend 取 0.5,say() 合成转录文本,sf.write 存成 final.flac,再对它 fingerprint。烘焙回来的嵌入与原混合不完全一致,但管理起来更容易——就是一个声音参考文件。

So on the question of using those embeddings for speaker recognition: how good does that work? I took an episode of State of Agentic Coding, the podcast Ben and I record, cut it into 2-second windows one second apart and compared each with an embedding of me from the conference. Somewhat surprising, but maybe not too surprising using my conference recording clip as the reference this does not work at all with Qwen3. If instead I take the 20 seconds where I introduce myself at the start of the episode as the reference, it gets 98% of the rest right. On the other hand using a model that was trained for speaker verification instead does manage to pick me up from the conference recording clip. For instance ECAPA, finds me from the conference clip in 97% of the windows.

那这些嵌入做说话人识别效果如何?我拿一期 Ben 和我录的播客 State of Agentic Coding,切成 2 秒窗、间隔 1 秒,逐窗与「会议录音的我」的嵌入比对。有点意外但也许不意外:用会议录音片段做参考,Qwen3 上**完全不管用**;改用播客开头 20 秒我自介绍的片段做参考,其余部分 98% 正确。而换成专为说话人验证训练的模型(如 ECAPA),用会议片段也能在 97% 的窗口里找到我。

Instructed Cloned Voices · 带指令的克隆声音

But the embeddings are very useful for the voice cloning! But how to give them instructions? Base and CustomVoice manipulate the speaker the same way: one vector inserted into the talker's input sequence. In CustomVoice that vector is a row of the embedding table. In Base it is the x-vector from the speaker encoder, which only Base has. The generation code takes either (voice_clone_prompt is a regular argument of generate), and the transformer weights of the two models are nearly identical. So take the embedding with Base, then hand it to CustomVoice instead of one of its speakers: 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 # 取 codes[0] 与 wavs[0]; 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.")

嵌入对声音克隆非常有用!但怎么给它指令?**Base 与 CustomVoice 操纵说话人的方式相同:一个向量插进说话人的输入序列。**CustomVoice 里那个向量是嵌入表的一行;Base 里是说话人编码器的 x-vector(只有 Base 有)。生成代码两者都收(voice_clone_prompt 是 generate 的常规参数),而两个模型的 transformer 权重几乎一致——所以:用 Base 提取嵌入,然后把它交给 CustomVoice 顶替某个内置说话人。say(text, xvec, instruct) 函数:CustomVoice 模型(Qwen3-TTS-12Hz-1.7B-CustomVoice)的 generate(temperature=0.7)里 voice_clone_prompt 传 {"ref_spk_embedding": [xvec], "x_vector_only_mode": [True], "icl_mode": [False]}、instruct_ids 传指令文本,克隆声音就能吃指令——示例:「用悲伤、哀恸的语气说,声音低沉厚重,几近落泪」地说「我真以为这能行」。

Instructions work on cloned voices, but less reliably than on the built-in speakers, and it depends on the emotion, but honestly, it came out better than I anticipated. Definitely good enough to have some true custom voices for a voice over that don't necessarily sound like something, someone else has in their game.

指令在克隆声音上有效,但不如内置说话人可靠、且因情感而异——不过说实话,比我预期的好。绝对够用:给你的配音弄出真正自定义的声音,不必听着像别人游戏里的东西。

Other Fun Math · 其他趣味数学

If a voice is a point in a 2048-dimensional space and blending works we can maybe do some other funny math on it. The obvious one is to try to see if some math can be used to manipulate gender. To find it I had VoiceDesign create 14 different voices for men and women each. When you then take the average woman minus the average man you can then move any voice along that direction: 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): x = xvec.float(); return (x + (t - (x - middle) @ axis) * axis).to(torch.bfloat16). Here is the result for my clone.

如果声音是 2048 维空间中的一个点、而且混合有效,那也许还能做点别的趣味数学。最显然的一个:看看能不能用数学操纵性别。为找这个方向,我让 VoiceDesign 各造了 14 个女声与男声;平均女人减平均男人得到方向轴(axis,归一化),middle 取两均值的中点(代码以 torch.bfloat16 精度运算);move(xvec, t) 把任意声音移到轴上位置 t(先算当前投影 (x-middle)@axis,再沿 axis 补到目标 t),其余维度不动。我克隆声音的效果见原文音频。

Anyways, that's that. If you want to play around with Qwen3 TTS I hope this was of some help, and maybe you got some ideas for your own project.

总之就这样。想玩玩 Qwen3-TTS 的话,希望这篇帮上了忙,也许还给你的项目添了点子。

回忆挑战

先不看答案自己回忆——讲不出来再回正文找。考的是关系,不是标签。

1为什么「克隆的声音不能吃指令」这个限制可以用跨模型组合破解?两个模型在这里扮演什么角色?

因为两个模型操纵说话人的机制同构:CustomVoice 从嵌入表取一行向量插入说话人输入序列,Base 用说话人编码器的 x-vector 插入同一个位置,而两者的 transformer 权重几乎一致——区别只在「向量从哪来」和「是否接指令输入」。所以用 Base 的编码器从录音提嵌入(拿到克隆身份),把嵌入塞给 CustomVoice(接上指令通道),克隆声音就获得了情感控制。本质是发现了两个模型的接口缝隙并拼接它们各自的长处。

2说话人识别实验里「会议录音做参考完全不工作、播客自介绍做参考 98% 正确」——这说明嵌入的什么特性?对实际使用有什么指导?

说明说话人嵌入对录音条件(信道/麦克风/混响)敏感:会议录像是 lapel 麦+会场声学,播客是常驻录音环境——参考与目标的声学条件差太远,同一人的嵌入距离反而大于阈值。指导:做识别时参考样本要与目标同源环境(同节目、同设备);跨环境识别要换对信道鲁棒的专用模型(ECAPA 用会议参考也 97%)。这与混合声音时「频谱相近效果更好」是同一个特性的一体两面。

3「不需要很干净或很长的录音就能克隆出以假乱真的声音」——从防御者视角,这个事实意味着什么?

意味着「保护声音」不能靠减少公开录音——28 秒的会议片段就够了,而公开演讲恰恰是最容易被收集的音频。真正的防线在信道侧:来电显示/回拨验证、约定暗语、以及对「电话里声音像本人」这个信号本身降权(就像不能只信邮件显示名)。声音已经从「不可伪造的身份凭证」降级为「可低成本合成的表面特征」——认证体系不该再单靠它。