面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记

给网站加朗读功能(二):声音复刻而不是通用音色

2026年7月26日2,9659分钟

给站内一个私密留言板加朗读功能时,一开始就排除了通用 TTS——想听到的是留言人自己的声音,不是一个标准播音腔。这篇记录方案选型和接入过程,但重点是搞懂声音复刻到底是怎么做到「用一段几十秒的录音就能复刻音色」的。


目录
  • TL;DR
  • 1. 架构设计
  • 2. 原理剖析
  • 2.1 语音怎么变成「带语义的 token」
  • 2.2 文本 + 音色 → 语义 token(LLM 部分)
  • 2.3 语义 token + 音色 → 声学特征(flow matching 部分)
  • 2.4 声纹模型到底认出了什么
  • 2.5 为什么要把参考音频当「上下文」塞两次
  • 2.6 训练技巧:先学会「没有参照也能生成」,再放大参照的影响力
  • 2.7 跨语言克隆的特殊处理
  • 2.8 串起来看
目录
  • TL;DR
  • 1. 架构设计
  • 2. 原理剖析
  • 2.1 语音怎么变成「带语义的 token」
  • 2.2 文本 + 音色 → 语义 token(LLM 部分)
  • 2.3 语义 token + 音色 → 声学特征(flow matching 部分)
  • 2.4 声纹模型到底认出了什么
  • 2.5 为什么要把参考音频当「上下文」塞两次
  • 2.6 训练技巧:先学会「没有参照也能生成」,再放大参照的影响力
  • 2.7 跨语言克隆的特殊处理
  • 2.8 串起来看
目录
  1. TL;DR
  2. 1. 架构设计
  3. 2. 原理剖析
  4. 2.1 语音怎么变成「带语义的 token」
  5. 2.2 文本 + 音色 → 语义 token(LLM 部分)
  6. 2.3 语义 token + 音色 → 声学特征(flow matching 部分)
  7. 2.4 声纹模型到底认出了什么
  8. 2.5 为什么要把参考音频当「上下文」塞两次
  9. 2.6 训练技巧:先学会「没有参照也能生成」,再放大参照的影响力
  10. 2.7 跨语言克隆的特殊处理
  11. 2.8 串起来看
TTS
相关文章
  • 01
    给网站加朗读功能(一):从 Web Speech API 到云端 TTS 的完整实现2026/03
  • 02
    AI 搜索内核(一):从字符串到符号2026/07
  • 03
    Claude Code 规模化实践(一):如何在大型代码库中工作2026/07
← 上一篇AI 搜索内核(一):从字符串到符号

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

TL;DR

本站已经有一套朗读能力:文章页的「听全文」按钮,背后是阿里云 NLS 的云端 TTS,之前写过完整实现。文章朗读听的是内容,通用音色没问题,没人在意《打磨 iOS Web Clip 体验》这篇是用哪个音色念的。但特定场景下,读者希望听到的是「这个人对你说的话」,念出来的如果是一个标准播音腔,那种任务感就没了。所以这次要做的不是一个简单的朗读,而是一次声音复刻:用特定人物的声音念出他自己写的字。

1. 架构设计

这套框架和站内现有的接口是完全独立的两套体系(账号、密钥、计费等都不共享):

图1:两套 TTS 体系互不相关

前置的声音注册是一次性操作,靠本地脚本实现。通过一段本地录音,调用百炼的 create_voice 接口拿到 voice_id:

scripts/test-voice-clone.mjs(简化)
const dataUrl = `data:${mimeType};base64,${data.toString("base64")}`;
const cloneRes = await fetch(`${BASE}/api/v1/services/audio/tts/customization`, {
  method: "POST",
  headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "voice-enrollment",
    input: { action: "create_voice", target_model: TARGET_MODEL, prefix, url: dataUrl },
  }),
});
const { output } = await cloneRes.json();
// output.voice_id: cosyvoice-v3.5-plus-guesta-<random-suffix>

实测发现 url 参数直接接受 base64 编码的 data: URL(Chen 注:百炼官方文档写的是 url 参数「必须是公开可访问的地址」,原计划是先上传到公开 Blob 拿链接再传给百炼。),不需要公开可访问的地址,省掉了一整段上传/清理公开样本的代码。

拿到 voice_id 后填进 .env.local,朗读接口按留言作者查各自的 voice_id,合成时只需要传 voice_id 和文本,不用再带上原始录音——这背后的原理是第 2 节的重点。

缓存策略上,Blob 的 key 用内容哈希而不是卡片 id:留言可编辑,如果 key 只用 id,编辑后播放会读到编辑前的旧音频。哈希变了 key 自然指向新文件,不需要写显式的失效逻辑:

图2:朗读请求的完整路径——缓存命中直接代理返回,未命中才调用百炼合成
src/app/api/board/cards/[id]/audio/route.ts(简化)
function audioPathname(cardId: string, content: string): string {
  const hash = createHash("sha256").update(content).digest("hex").slice(0, 12);
  return `board-audio/${cardId}-${hash}.mp3`;
}
 















一个路由、一个脚本,接口调用本身不复杂。真正有意思的问题是:voice_id 到底存了什么,让合成时不用再带原始录音就能还原音色?

2. 原理剖析

阿里云百炼的产品页面没有公开 CosyVoice 的内部实现细节,但从命名(voice-enrollment、create_voice、cosyvoice-v3.5-plus)看,走的应该是同一套技术脉络:阿里达摩院公开发表的 CosyVoice 论文 。该论文详细说明了这类零样本(zero-shot)(Chen 注:零样本指模型不需要针对这个新说话人重新训练或微调,只靠一小段参考音频就能提取音色特征并直接合成。「样本」指的是训练样本,不是推理时的参考音频。)声音复刻系统的通用架构。理解它,才能理解「注册声音」和「合成语音」这两步各自在做什么。

整条链路分为三段:文本先变成语义 token,语义 token 再变成声学特征(mel 频谱(Chen 注:mel 频谱是把音频的频率轴按人耳感知特性(对低频更敏感、对高频更粗略)重新映射后的时频图,比原始波形更紧凑,也更贴近人耳实际听到的信息量,是语音合成里声学特征的常见表示。)),最后声码器把频谱还原成波形。

图3:CosyVoice 的三级解码链路

2.1 语音怎么变成「带语义的 token」

这是 CosyVoice 的核心创新。普通的语音编解码器(如 Encodec)把音频压缩成 token 时只保留声学细节,token 和文字之间没有直接对应关系。CosyVoice 反过来,把 token 化直接嵌进一个语音识别(ASR)模型里:编码器前六层照常提取声学特征,中间硬插入一层向量量化(一个 4096 大小的 codebook),量化后的结果再送进后面几层编码器,最后接一个 ASR 解码器去预测文字:

图4:speech tokenizer 内部结构——向量量化插在 ASR 编码器中间

整套 tokenizer 是在「能不能正确识别出文字」这个监督信号下训练出来的——训练时同时要求 codebook 里的量化向量既能重建声学特征,又能被 ASR 解码器正确识别出对应文字。所以量化出来的 token 天然带着语义对齐信息,这也是论文标题里「supervised semantic tokens」的意思。真正在推理时对外产出、后续会被 LLM 消费的,是 VQ 那一层输出的离散 token,ASR 解码器只在训练阶段起监督作用。

2.2 文本 + 音色 → 语义 token(LLM 部分)

目标文本先做 BPE 分词、过一层 text encoder,和一个音色特征拼成一个序列喂给自回归 Transformer——本质上跟大语言模型生成文字 token 的方式一样,只是这里生成的是语音语义 token。论文里这个输入序列的构造方式(简化自原文公式)大致是这样:

LLM 输入序列结构(简化自论文)
[S, v, y_1, y_2, ..., y_U, T, μ_1, μ_2, ..., μ_L, E]
 │  │  └──────┬──────┘   │  └──────┬──────┘   │
 │  │      文本编码        │     语音语义 token    结束符
 │  speaker embedding   分隔符
 起始符

v 这个音色特征就是 speaker embedding——不是 LLM 自己学出来的,而是用一个独立预训练好的声纹识别模型(阿里 3D-Speaker 项目的 CAM++,跟人脸识别里的人脸特征向量是类似的东西)从参考录音里单独提取出来的一个向量,权重固定,只做特征提取,拼进序列头部给 LLM 当条件。训练时 LLM 只对 μ 和结束符 E 算损失,文本部分只是 condition,不是预测目标。这一步的输出还只是语义 token,不含声学细节——念的内容对了,但还没有「音色」。

2.3 语义 token + 音色 → 声学特征(flow matching 部分)

Conditional flow matching 是一种比传统扩散模型训练更简单、生成更快的生成式模型,负责把上一步的语义 token 变成 mel 频谱。关键在于它的条件输入里除了语义 token,还同时塞进了同一个 speaker embedding,以及参考录音的 mel 频谱(一份从中间挖空一段的版本,逼着模型学会靠已有的声学上下文去补全,而不是死记硬背):

图5:flow matching 的条件输入——语义 token、speaker embedding、遮蔽后的参考 mel 频谱共同驱动生成

也就是说,音色信息被喂了两次——一次在生成语义内容时提供大方向,一次在生成声学细节时精确控制音高、音质、口音这些具体听感——这是零样本克隆效果好的关键设计,只用一次 embedding 精度不够。

2.4 声纹模型到底认出了什么

提取 speaker embedding 用的那个声纹模型(阿里 3D-Speaker 项目的 CAM++),训练目标跟「生成语音」完全是两件事——它是一个说话人验证(speaker verification)模型,学的是「分辨是不是同一个人」:

说话人验证模型的训练目标(简化)
distance(embed(person_A_clip1), embed(person_A_clip2))  →  尽量小
distance(embed(person_A_clip1), embed(person_B_clip1))  →  尽量大

同一个人不同录音的 embedding 要挤得足够近,不同人的 embedding 要拉得足够远,这跟人脸识别几乎是同一套思路(同一张脸不同角度的照片特征要接近,不同人的照片要区分开)。副产品是,训练好之后,任何一段陌生人的新录音过一遍这个模型,都能拿到一个稳定描述「这是谁的声音」的向量,完全不需要这个人出现在训练数据里——这正是「zero-shot」(对没见过的新说话人也能用)成立的根源,也是为什么克隆一段没在任何数据集里出现过的普通人声音也能生效。

2.5 为什么要把参考音频当「上下文」塞两次

一个几百维的向量能装下音高范围、大致音色倾向这类粗粒度信息,但装不下具体的咬字习惯、气息节奏、口音细节——这些更精细的东西如果只靠一个固定向量去描述,会在生成阶段被抹平。CosyVoice 的解法是再加一层「上下文」:把参考录音真实的 mel 频谱也喂给 flow matching 模型,但训练时故意把这份频谱从某个随机位置开始挖空到结尾:

参考 mel 频谱的遮蔽构造(简化自论文思路)
function maskReferenceMel(mel: number[][], randomStart: number): number[][] {
  // randomStart 到结尾的帧全部置零,逼模型学会"看着前半段真实频谱,补全后半段"
  return mel.map((frame, i) => (i >= randomStart ? frame.map(() => 0) : frame));
}

这跟语言模型的 few-shot 提示是同一个思路——不是告诉模型「这个人的音色是什么样」,而是直接把这个人的一段真实声音摆在眼前当参照,让模型照着往后接。训练时挖空的位置是随机的,推理时则把整段参考频谱完整保留在前面,模型只需要接着往后写——这也是为什么参考录音的质量和长度会直接影响克隆效果:录音越干净、有效发音越多,模型能「抄」的素材就越丰富。

2.6 训练技巧:先学会「没有参照也能生成」,再放大参照的影响力

flow matching 训练时会以 20% 的概率随机丢掉全部条件(speaker embedding、语义 token、参考频谱都不给),逼模型同时学会「有参照时怎么生成」和「完全没参照时怎么生成」。推理阶段,模型分别按「有条件」和「无条件」各跑一次,再按一个固定强度把两次预测的差值放大叠加回去:

Classifier-free guidance(简化自论文公式)
v_cond   = NN(x, t; v, μ, ref_mel)   # 有条件预测
v_uncond = NN(x, t; ∅)               # 无条件预测(训练时随机丢弃条件)
v_guided = v_uncond + β · (v_cond - v_uncond)   # β ≈ 0.7,放大条件的影响力

这就是 classifier-free guidance,图像生成模型(比如 Stable Diffusion 用文字 prompt 生成图片)用的是同一招。效果是让合成结果更贴近参考音色,而不是被训练数据里各种声音「平均」掉、退化成一个更像大多数人的中庸嗓音。

2.7 跨语言克隆的特殊处理

如果参考录音和要念的文本不是同一种语言(比如拿一段中文录音去合成英文),系统会直接丢掉参考录音对应的文本和语义 token,只留 speaker embedding 和参考频谱:

图6:跨语言克隆时丢弃参考文本与语义 token,只保留音色相关信息

原因是语义 token 里混杂着原语言的语调和停顿习惯,直接拼进目标语言的生成序列会把说话习惯「带歪」——索性只保留音色相关的信息,不保留语言相关的信息,让目标语言的语调完全交给 LLM 按目标语言的习惯自己生成。

2.8 串起来看

给一段参考录音和目标文本,系统会同时用到参考录音的三类信息——参考录音本身过一遍 tokenizer 得到的语义 token(当作「已经生成好」的前缀,直接拼进 LLM 的输入序列里,让 LLM 顺着这个前缀续写新文本对应的 token,跨语言场景下会跳过这一项);从参考录音提取的 speaker embedding(同一个向量,分别注入 LLM 和 flow matching 两个阶段,提供粗粒度的音色方向);参考录音的 mel 频谱(只在 flow matching 阶段作为上下文条件,补上精细的声学细节)。三类信息各自负责不同粒度,缺一个,克隆出来的相似度都会打折扣。

回到「注册声音」这一步:create_voice 拿到的一段录音,本质上就是在做上面这套流程里「提取参考信息」的部分——声纹向量、语义 token、参考频谱——然后把这些信息存在服务端,绑定成一个 voice_id。后续合成只需要传 voice_id 和新文本,服务端直接用存好的参考信息去跑 LLM 和 flow matching 两段,不需要每次都重新上传原始录音。这也解释了为什么一段几十秒的录音就够:需要提取的不是完整的音频内容,只是一个音色向量和一段可复用的声学上下文,剩下的生成工作全靠这两段训练时学到的通用能力去补。


功能本身不复杂,一个路由、一个组件、一个一次性脚本。真正值得记下来的是背后的原理:声音复刻不是「录一段音、原样重放」,而是把音色拆成向量和上下文两种可复用的表示,之后每次合成都是拿着这两样东西重新生成,而不是简单的音频拼接或变声。

export async function GET(req, { params }) {
const { id } = await params;
const card = await getCard(id);
const voiceId = voiceIdForAuthor(card.author); // 按作者查各自 voice_id
if (!voiceId) return NextResponse.json({ error: "forbidden" }, { status: 403 });
const pathname = audioPathname(id, card.content);
const cached = await get(pathname, { access: "private", token });
if (cached?.statusCode === 200 && cached.stream) {
return new NextResponse(cached.stream, { headers: { "Content-Type": "audio/mpeg" } });
}
const audio = await synthesize(card.content, voiceId); // 调百炼 SpeechSynthesizer
await put(pathname, audio, { access: "private", token, contentType: "audio/mpeg" });
return new NextResponse(new Uint8Array(audio), { headers: { "Content-Type": "audio/mpeg" } });
}