语音合成的革命
Fish Speech 如何用 1000 万小时音频、80+ 语言重新定义开源语音合成
想象一下:把文字变成声音
你有没有用过手机的「朗读」功能?或者听过导航软件说「前方路口左转」?这些声音背后就是 TTS(文本转语音) 技术。传统 TTS 听起来像机器人,而 Fish Speech 的目标只有一个:让合成的声音跟真人一模一样。
Fish Speech S2 Pro 用超过 1000 万小时的音频训练,覆盖 80 多种语言。它不只是「读字」,而是理解语气、情感、节奏——就像一个真正的演员在朗读剧本。
80+ 语言
中文、英文、日文、韩文……一个模型搞定全球主要语言,不需要切换引擎。
情感控制
用自然语言标签控制语气:[whisper] 耳语、[excited] 激动、[angry] 愤怒——不需要调参数。
声音克隆
只需几秒钟的参考音频,就能复制任何人的音色和说话风格。
多角色对话
原生支持多说话者、多轮对话——生成一整段播客或电台节目。
源码地图:项目长什么样
打开 Fish Speech 的代码仓库,你会看到这样的目录结构。每个文件夹负责不同的工作,就像一家录音棚的不同部门:
一张图看懂 Fish Speech 工作原理
当你输入一段文字让 Fish Speech 朗读时,数据经历了这样一段旅程:
代码直译:文本清洗
一切从清洗用户输入开始。这段代码负责把杂乱的文字变得干净整齐——就像广播主持人拿到手稿后先「扫一眼」去掉不合适的符号:
def clean_text(text):
text = text.strip()
text = REPLACE_SYMBOL_REGEX.sub(
lambda x: SYMBOLS_MAPPING[x.group()], text)
text = EMOJI_REGEX.sub(r"", text)
text = re.sub(r"[,]{2,}",
lambda m: m.group()[0], text)
return text
定义一个函数,接收原始文本作为输入。
先去掉文字首尾多余的空格。
把中文标点(如全角引号)替换成英文标点。
这一行是「参数和函数匹配」的细节,跳过也行。
删掉所有表情符号(emoji)——机器不需要表情。
把连续的逗号压缩成一个,避免合成时停顿太多。
返回清洗后的干净文本。
检验理解
Fish Speech 是一个什么类型的系统?
在 clean_text 函数中,表情符号(emoji)会被怎么处理?
如果你要向朋友解释 Fish Speech 的核心创新,你会说它的架构叫什么?
Dual-AR 双自回归架构
两个 Transformer 如何像指挥家和乐队一样协作,把文字变成声音蓝图
比喻:指挥家与交响乐团
想象一场交响乐演出。 自回归模型 就像一个只能一步一步做事的音乐家。Fish Speech 的创新在于:它有两个自回归模型,分工不同。
负责「大局观」——理解文本语义,决定什么时候该说什么。就像指挥家决定乐曲的节奏和情感走向。32 层深的神经网络,视野广阔但速度较慢。
负责「细节执行」——根据指挥的意图,快速生成每一帧音频的精细参数。4 层轻量网络,速度飞快,但只关注眼前这一帧。
传统方案用一个模型同时理解语义和生成音频,结果两头都做不好。Dual-AR 把这两个任务拆开:慢模型专注「理解文字要表达什么」,快模型专注「这些语义对应什么声音细节」。分工明确,效果倍增。
对话动画:指挥家与演奏家的协作
点击「下一条消息」,看两个模型如何配合完成一次语音生成:
代码直译:Dual-AR 模型结构
来看看两个 Transformer 是如何在代码中被定义的。注意看「慢速」和「快速」部分是如何分工的:
class DualARTransformer(BaseTransformer):
def __init__(self, config):
# 继承:慢速 Transformer(32 层)
super().__init__(config)
# 快速 Transformer(4 层)
self.fast_embeddings = nn.Embedding(
config.codebook_size, config.fast_dim)
self.fast_layers = nn.ModuleList(
TransformerBlock(config_fast)
for _ in range(config.n_fast_layer))
定义双自回归 Transformer 类,继承基础模型。
构造函数接收配置参数。
调用父类构造——创建 32 层「慢速」Transformer。
这行是注释,说明下面是快速部分。
创建快速部分的词嵌入层。
嵌入维度等于码本大小(如 160)映射到快速模型的维度。
创建快速 Transformer 的层列表。
每一层都是独立的 TransformerBlock,使用精简配置。
共创建 n_fast_layer 层(默认 4 层)。
交互式架构图
点击任意组件,查看它在系统中的角色:
慢速 Transformer(理解语义)
快速 Transformer(生成音频细节)
检验理解
慢速 Transformer 和快速 Transformer 分别有多少层?
在推理过程中,什么机制防止模型陷入重复循环?
从文本到声波:推理流水线
追踪一段文字从输入到变成声音的完整旅程
比喻:一条汽车生产线
把 Fish Speech 的推理过程想象成一条汽车生产线。原材料(文字)从一端进去,成品(音频)从另一端出来。中间经过多个工位,每个工位负责一道工序:
把用户输入的文字包装成「对话消息」格式——加上 system 指令、user 消息标记。就像给原材料贴上工序标签。
把对话文本用 分词器 转成数字 ID 序列。每个汉字或标点变成一个数字。
Dual-AR 模型逐个生成 语义 Token ,每个 Token 经快速模型扩展成 4 层码本。直到遇到结束标记。
4 层码本交给 DAC 编解码器,还原成真实的声波数据,保存为 WAV 文件。
代码直译:生成循环的核心
这是推理过程中最关键的循环。它一步步生成 Token,直到文本全部转为音频蓝图:
for i in range(num_new_tokens):
next_token = decode_one_token(
model=model,
x=cur_token,
input_pos=input_pos,
temperature=temperature,
top_p=top_p,
).clone()
input_pos += 1
previous_tokens = previous_tokens.roll(-1)
new_tokens.append(next_token)
if cur_token == im_end_id:
break
循环最多 num_new_tokens 次(设置上限防死循环)。
调用核心函数,用模型生成下一个 Token。
传入当前模型和当前 Token。
传入当前位置(告诉模型「我们走到第几步了」)。
温度参数控制随机性——越高越有创意,越低越保守。
Top-P 参数控制采样范围——只考虑概率前 90% 的候选。
克隆结果,避免引用问题。
位置指针前进一位——准备生成下一个。
滑动窗口左移一位——为 RAS 重复检测做准备。
收集所有生成的 Token。
如果遇到「结束」标记……
跳出循环,生成完毕!
消息流动画:一次完整的推理过程
点击「下一步」,跟着数据走完从文字到声音的全过程:
三大调音旋钮:Temperature / Top-P / Top-K
生成过程中有三个关键参数控制输出质量。它们就像调音台上的旋钮:
Temperature(温度)
默认 1.0。值越高,输出越「有创意」(随机性强);值越低,越「保守」(倾向高概率选择)。类比:温度高了水会沸腾(混乱),低了会结冰(固定)。
Top-P(核采样)
默认 0.9。只从概率累计前 90% 的候选中选——自动过滤掉「不太可能」的选项。类比:考试只从前 90% 常考题里出题。
Top-K
默认 30。只考虑概率最高的前 30 个候选。类比:菜单上有 100 道菜,你只从前 30 道最热门的里选。
代码直译:采样算法
这三个参数在代码中是如何协同工作的?看看核心采样函数:
def logits_to_probs(logits, temperature, top_p, top_k):
sorted_logits, sorted_indices = torch.sort(
logits, descending=True)
cum_probs = torch.cumsum(
F.softmax(sorted_logits, dim=-1), dim=-1)
top_k_mask = indices >= top_k
sorted_to_remove = (cum_probs > top_p) | top_k_mask
logits = logits / torch.clip(
temperature, min=1e-5)
probs = F.softmax(logits, dim=-1)
return probs
定义采样函数,接收原始预测分数和三个参数。
把所有候选按概率从高到低排序。
排序的具体实现。
计算累计概率——从最高到最低逐步累加。
Softmax 把原始分数转成 0-1 的概率值。
标记排在 Top-K 之外的候选。
标记累计概率超过 Top-P 的候选——它们被排除。
除以温度——温度越高,概率分布越平坦(更随机)。
防止温度为 0 导致除零错误。
最终转换为概率分布(所有值加起来等于 1)。
返回概率——模型将从中随机选一个 Token。
检验理解
你正在用 Fish Speech 合成一段有声读物。合成出来的声音太「跳跃」,经常在句子中间突然变调。你会怎么调整参数?
semantic_logit_bias 的作用是什么?
音频编解码:VQ 码本世界
声音如何被压缩成数字,又如何从数字还原成声音
比喻:调色板与画作
想象一位画家有一套特殊的调色板。这个调色板不是无限的——它只有 160 种颜色,每种颜色有一个编号(0-159)。画家的画作(声音)需要被「翻译」成这些编号的组合。
这就是 向量量化(VQ) 的核心思想。Fish Speech 用 4 层码本(4 个调色板叠加),每层 160 个「颜色」,来表示音频的方方面面:
码本 0
基础音色——声音的「底色」,区分不同人的嗓音
码本 1
音高变化——声音的「旋律」,语句中的抑扬顿挫
码本 2
时长节奏——声音的「节拍」,决定每个音拉多长
码本 3
细节修饰——声音的「质感」,呼吸声、唇齿音等微妙特征
4 层码本形成了一种「残差编码」——每层编码上一层没能表达的信息。就像你先画大致轮廓(码本 0),再添加阴影(码本 1),然后是细节(码本 2),最后是高光(码本 3)。层层递进,越来越精细。
代码直译:VQ 编码与解码
VQManager 是管理「编码→解码」全过程的管家。看看它如何把参考音频压缩成码本,又如何从码本还原声音:
class VQManager:
def encode_reference(self, audio, enable_ref):
audios = torch.from_numpy(audio)
audio_lengths = torch.tensor([audios.shape[2]])
prompt_tokens = self.decoder_model.encode(
audios, audio_lengths)[0][0]
return prompt_tokens
def decode_vq_tokens(self, codes):
return self.decoder_model.from_indices(
codes[None])[0].squeeze()
VQManager 类——音频编解码的「大管家」。
encode_reference:把参考音频压缩成码本。
把 NumPy 音频数据转成 PyTorch 张量。
记录音频长度信息。
调用 DAC 模型的 encode 方法——核心压缩!
取出第一个 batch 的第一个码本结果。
返回压缩后的码本 Token(4 层,每层是 0-159 的数字)。
空行分隔两个方法。
decode_vq_tokens:把码本还原成声音。
调用 from_indices——把码本数字映射回声波。
去掉 batch 维度,返回单条音频。
互动匹配:码本层次与功能
把右侧的码本层次拖到对应的功能描述上:
呼吸声、唇齿音、气流声等微妙特征——「声音质感」
区分不同人的嗓音——张三和李四的「底色」不同
每个音节拉多长、停顿多短——「说话节奏」
语句中的抑扬顿挫——疑问句升调、陈述句降调
DAC 编解码器:声音的压缩与还原
DAC(Descript Audio Codec) 是 Fish Speech 使用的音频编解码器。它的工作就像 JPEG 压缩图片——有损但足够好:
原始声波(连续信号)
编码器提取特征
RVQ 量化为 4 层码本
解码器还原声波
def decode_to_audio(codes, codec):
# codes: (4, T) 4 层码本,每层 T 个时间步
audio = codec.from_indices(
codes[None]) # 加 batch 维度
return audio[0, 0] # 取出单声道
定义解码函数:从码本还原音频。
输入是 4 层码本,每层 T 个时间步的数字序列。
调用 codec 的 from_indices 方法开始还原。
加上 batch 维度(模型需要这种格式)。
取出第一条、第一个声道——返回纯净的单声道音频。
检验理解
你想用 Fish Speech 克隆自己的声音。你提供了一段 5 秒的参考音频。VQManager 会怎么处理这段音频?
「残差编码」是什么意思?
对话式 TTS 与工程实战
多角色对话、声音克隆、API 部署——从实验室到产品
比喻:一部广播剧的制作
想象你是一位广播剧导演。剧本上有多个角色,每个角色有自己的台词和声音。Fish Speech 的对话系统(Conversation)就像你的剧本管理系统:
告诉模型全局规则——「把下面的文字转成语音」或「参考这个声音来生成」。这是给 AI 的「导演注释」。
要被合成的实际文字内容。可以是单个说话者的台词,也可以用 <|speaker:0|> 标签标注多个说话者。
模型生成的音频码本。上一轮生成的音频会成为下一轮的「记忆」,保持声音连贯性。
代码直译:多角色对话构建
这段代码展示了如何构建一个带声音克隆的多轮对话。注意 system 消息如何包含参考音频:
base_conversation = Conversation()
system_parts = [
TextPart(text="convert text to speech"),
TextPart(text=reference_text),
VQPart(codes=all_reference_codes),
]
base_conversation.append(
Message(role="system",
parts=system_parts,
modality="voice"))
base_conversation.append(
Message(role="user",
parts=[TextPart(text=batch_text)]))
创建一个空对话——就像拿了一张空白剧本。
准备 system 消息的各个部分。
文字指令:「把文字转成语音」。
参考文本:你要克隆的声音说过的话。
参考音频码本:从你的声音中提取的数字指纹。
把 system 消息添加到对话中。
角色是 system(导演指示)。
包含上面准备的三部分内容。
模式设为 voice(语音生成模式)。
添加用户消息——要合成的文字。
角色是 user(演员台词)。
内容就是这一批要转成声音的文字。
多角色标记系统
Fish Speech 用特殊标签实现多角色对话。看看文本中如何标注不同说话者:
<|speaker:0|>你好,欢迎收听今天的节目。
<|speaker:1|>谢谢!今天我们要聊的话题很有趣。
<|speaker:0|>没错,让我们开始吧!
说话者 0(如主持人):说欢迎语。
说话者 1(如嘉宾):回应并引出话题。
说话者 0:回应并推进对话。
代码中的 split_text_by_speaker 函数自动识别这些标签,把长文本拆成多个角色轮次:
def split_text_by_speaker(text):
pattern = r"(<\|speaker:\d+\|>)"
parts = re.split(pattern, text)
turns = []
while i < len(parts):
if re.match(pattern, part):
turn = part + parts[i+1]
turns.append(turn)
return turns
定义函数:按说话者标签拆分文本。
正则表达式匹配 <|speaker:数字|> 格式的标签。
用正则把文本按标签切开。
准备收集各轮对话。
遍历切好的片段。
如果当前片段是说话者标签……
把标签和后面的文字拼成一条完整台词。
添加到轮次列表。
返回所有说话者的轮次列表。
Tokenizer:语音世界的「字典」
FishTokenizer 定义了整个系统使用的特殊标记。这些标记是模型内部沟通的「暗号」:
消息边界标记
<|im_start|> 和 <|im_end|> 标记每条消息的开始和结束——像信封的封口。
语义 Token 空间
<|semantic:0|> 到 <|semantic:4095|> 共 4096 个标记——这是慢速 Transformer 的输出空间。
模态标记
<|text|> 纯文字模式、<|voice|> 语音模式、<|interleave|> 交错模式——告诉模型用什么方式工作。
音频标记
<|audio_start|> 和 <|audio_end|> 标记音频区域的边界——像书签标记哪一页有插图。
对话动画:API 服务器如何工作
在真实部署中,多个组件协同工作。点击「下一条消息」看完整的服务流程:
找 Bug 挑战
下面这段代码有一个会导致生成结果严重重复的 Bug。点击你认为有问题的那一行:
找出这段推理代码中的 Bug:
for i in range(num_new_tokens):
next_token = decode_one_token(model, cur_token, input_pos)
new_tokens.append(next_token)
input_pos += 1
if cur_token == im_end_id: break
工程技巧:长文本分块生成
真实场景中,文本可能很长(如一整篇文章)。Fish Speech 用智能分块策略处理:
def group_turns_into_batches(
turns, max_speakers=3, max_bytes=300):
for turn in turns:
would_exceed = (
len(batch) >= max_speakers
or cur_bytes + turn_bytes > max_bytes)
if would_exceed and current_batch:
batches.append(current_batch)
current_batch = [turn]
return batches
定义分块函数:把长对话拆成小块。
限制:每块最多 3 个说话者,最多 300 字节。
遍历每一轮对话。
检查是否超过限制:
说话者数量是否已达上限?
或者字节数是否超过限制?
如果超了,当前块结束。
保存当前块,开始新的一块。
新块从当前轮次开始。
返回所有分好块的结果。
模型有最大序列长度限制(如 2048 Token)。超过限制就生成不了。分块策略保证了每一块都在模型能处理的范围内。同时,每块生成完后,结果会作为「历史记忆」加入下一块的输入——保持声音的连贯性。
最终检验
你需要为一个播客应用生成一段 10 分钟的对话音频,包含 3 个说话者。你会如何设计调用方案?