01

语音合成的革命

Fish Speech 如何用 1000 万小时音频、80+ 语言重新定义开源语音合成

想象一下:把文字变成声音

你有没有用过手机的「朗读」功能?或者听过导航软件说「前方路口左转」?这些声音背后就是 TTS(文本转语音) 技术。传统 TTS 听起来像机器人,而 Fish Speech 的目标只有一个:让合成的声音跟真人一模一样。

!
核心洞察

Fish Speech S2 Pro 用超过 1000 万小时的音频训练,覆盖 80 多种语言。它不只是「读字」,而是理解语气、情感、节奏——就像一个真正的演员在朗读剧本。

🌐

80+ 语言

中文、英文、日文、韩文……一个模型搞定全球主要语言,不需要切换引擎。

🎭

情感控制

用自然语言标签控制语气:[whisper] 耳语、[excited] 激动、[angry] 愤怒——不需要调参数。

🎙️

声音克隆

只需几秒钟的参考音频,就能复制任何人的音色和说话风格。

👥

多角色对话

原生支持多说话者、多轮对话——生成一整段播客或电台节目。

源码地图:项目长什么样

打开 Fish Speech 的代码仓库,你会看到这样的目录结构。每个文件夹负责不同的工作,就像一家录音棚的不同部门:

fish_speech/ 核心代码——语音合成的「大脑」
models/ AI 模型定义——最核心的神经网络
text2semantic/ 文本→语义 Token 转换(大模型)
dac/ 音频编解码器(DAC Codec)
inference_engine/ 推理引擎——把模型变成可用服务
conversation.py 对话管理——组织多轮对话结构
tokenizer.py 分词器——定义特殊标记和语义空间
text/clean.py 文本清洗——去表情符号、规范化标点
tools/ 工具箱——API 服务器、WebUI、客户端

一张图看懂 Fish Speech 工作原理

当你输入一段文字让 Fish Speech 朗读时,数据经历了这样一段旅程:

T
文本输入
Tk
分词器
AR
Dual-AR
DAC
音频解码
W
音频输出
点击「下一步」开始动画

代码直译:文本清洗

一切从清洗用户输入开始。这段代码负责把杂乱的文字变得干净整齐——就像广播主持人拿到手稿后先「扫一眼」去掉不合适的符号:

Python

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 的核心创新,你会说它的架构叫什么?

02

Dual-AR 双自回归架构

两个 Transformer 如何像指挥家和乐队一样协作,把文字变成声音蓝图

比喻:指挥家与交响乐团

想象一场交响乐演出。 自回归模型 就像一个只能一步一步做事的音乐家。Fish Speech 的创新在于:它有两个自回归模型,分工不同。

🎼
慢速 Transformer(指挥家)

负责「大局观」——理解文本语义,决定什么时候该说什么。就像指挥家决定乐曲的节奏和情感走向。32 层深的神经网络,视野广阔但速度较慢。

🎻
快速 Transformer(首席演奏家)

负责「细节执行」——根据指挥的意图,快速生成每一帧音频的精细参数。4 层轻量网络,速度飞快,但只关注眼前这一帧。

i
为什么需要两个?

传统方案用一个模型同时理解语义和生成音频,结果两头都做不好。Dual-AR 把这两个任务拆开:慢模型专注「理解文字要表达什么」,快模型专注「这些语义对应什么声音细节」。分工明确,效果倍增。

对话动画:指挥家与演奏家的协作

点击「下一条消息」,看两个模型如何配合完成一次语音生成:

代码直译:Dual-AR 模型结构

来看看两个 Transformer 是如何在代码中被定义的。注意看「慢速」和「快速」部分是如何分工的:

Python

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(理解语义)

T
文本嵌入层
VQ
码本嵌入层
32x
Transformer 层
O
语义 Token 输出

快速 Transformer(生成音频细节)

E
快速嵌入层
4x
快速 Transformer 层
C
码本输出(4 层)
点击上方任意组件查看详细说明

检验理解

慢速 Transformer 和快速 Transformer 分别有多少层?

在推理过程中,什么机制防止模型陷入重复循环?

03

从文本到声波:推理流水线

追踪一段文字从输入到变成声音的完整旅程

比喻:一条汽车生产线

把 Fish Speech 的推理过程想象成一条汽车生产线。原材料(文字)从一端进去,成品(音频)从另一端出来。中间经过多个工位,每个工位负责一道工序:

1
铸造原料:对话构建(Conversation)

把用户输入的文字包装成「对话消息」格式——加上 system 指令、user 消息标记。就像给原材料贴上工序标签。

2
冲压成型:分词编码(Tokenize)

把对话文本用 分词器 转成数字 ID 序列。每个汉字或标点变成一个数字。

3
核心加工:模型生成(Generate)

Dual-AR 模型逐个生成 语义 Token ,每个 Token 经快速模型扩展成 4 层码本。直到遇到结束标记。

4
组装出厂:音频解码(DAC Decode)

4 层码本交给 DAC 编解码器,还原成真实的声波数据,保存为 WAV 文件。

代码直译:生成循环的核心

这是推理过程中最关键的循环。它一步步生成 Token,直到文本全部转为音频蓝图:

Python

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。

如果遇到「结束」标记……

跳出循环,生成完毕!

消息流动画:一次完整的推理过程

点击「下一步」,跟着数据走完从文字到声音的全过程:

U
用户输入
Tk
分词编码
AR
Dual-AR
DAC
音频输出
点击「下一步」开始动画

三大调音旋钮:Temperature / Top-P / Top-K

生成过程中有三个关键参数控制输出质量。它们就像调音台上的旋钮:

🌡️

Temperature(温度)

默认 1.0。值越高,输出越「有创意」(随机性强);值越低,越「保守」(倾向高概率选择)。类比:温度高了水会沸腾(混乱),低了会结冰(固定)。

📊

Top-P(核采样)

默认 0.9。只从概率累计前 90% 的候选中选——自动过滤掉「不太可能」的选项。类比:考试只从前 90% 常考题里出题。

🎯

Top-K

默认 30。只考虑概率最高的前 30 个候选。类比:菜单上有 100 道菜,你只从前 30 道最热门的里选。

代码直译:采样算法

这三个参数在代码中是如何协同工作的?看看核心采样函数:

Python

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 的作用是什么?

04

音频编解码:VQ 码本世界

声音如何被压缩成数字,又如何从数字还原成声音

比喻:调色板与画作

想象一位画家有一套特殊的调色板。这个调色板不是无限的——它只有 160 种颜色,每种颜色有一个编号(0-159)。画家的画作(声音)需要被「翻译」成这些编号的组合。

这就是 向量量化(VQ) 的核心思想。Fish Speech 用 4 层码本(4 个调色板叠加),每层 160 个「颜色」,来表示音频的方方面面:

码本 0 基础音色——声音的「底色」,区分不同人的嗓音
码本 1 音高变化——声音的「旋律」,语句中的抑扬顿挫
码本 2 时长节奏——声音的「节拍」,决定每个音拉多长
码本 3 细节修饰——声音的「质感」,呼吸声、唇齿音等微妙特征
!
关键洞察

4 层码本形成了一种「残差编码」——每层编码上一层没能表达的信息。就像你先画大致轮廓(码本 0),再添加阴影(码本 1),然后是细节(码本 2),最后是高光(码本 3)。层层递进,越来越精细。

代码直译:VQ 编码与解码

VQManager 是管理「编码→解码」全过程的管家。看看它如何把参考音频压缩成码本,又如何从码本还原声音:

Python

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 维度,返回单条音频。

互动匹配:码本层次与功能

把右侧的码本层次拖到对应的功能描述上:

码本 0(基础音色)
码本 1(音高变化)
码本 2(时长节奏)
码本 3(细节修饰)

呼吸声、唇齿音、气流声等微妙特征——「声音质感」

拖放到这里

区分不同人的嗓音——张三和李四的「底色」不同

拖放到这里

每个音节拉多长、停顿多短——「说话节奏」

拖放到这里

语句中的抑扬顿挫——疑问句升调、陈述句降调

拖放到这里

DAC 编解码器:声音的压缩与还原

DAC(Descript Audio Codec) 是 Fish Speech 使用的音频编解码器。它的工作就像 JPEG 压缩图片——有损但足够好:

1

原始声波(连续信号)

->
2

编码器提取特征

->
3

RVQ 量化为 4 层码本

->
4

解码器还原声波

Python

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 会怎么处理这段音频?

「残差编码」是什么意思?

05

对话式 TTS 与工程实战

多角色对话、声音克隆、API 部署——从实验室到产品

比喻:一部广播剧的制作

想象你是一位广播剧导演。剧本上有多个角色,每个角色有自己的台词和声音。Fish Speech 的对话系统(Conversation)就像你的剧本管理系统:

S
System 消息(导演指示)

告诉模型全局规则——「把下面的文字转成语音」或「参考这个声音来生成」。这是给 AI 的「导演注释」。

U
User 消息(演员台词)

要被合成的实际文字内容。可以是单个说话者的台词,也可以用 <|speaker:0|> 标签标注多个说话者。

A
Assistant 消息(生成的声音)

模型生成的音频码本。上一轮生成的音频会成为下一轮的「记忆」,保持声音连贯性。

代码直译:多角色对话构建

这段代码展示了如何构建一个带声音克隆的多轮对话。注意 system 消息如何包含参考音频:

Python

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 函数自动识别这些标签,把长文本拆成多个角色轮次:

Python

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|> 标记每条消息的开始和结束——像信封的封口。

S

语义 Token 空间

<|semantic:0|><|semantic:4095|> 共 4096 个标记——这是慢速 Transformer 的输出空间。

M

模态标记

<|text|> 纯文字模式、<|voice|> 语音模式、<|interleave|> 交错模式——告诉模型用什么方式工作。

A

音频标记

<|audio_start|><|audio_end|> 标记音频区域的边界——像书签标记哪一页有插图。

对话动画:API 服务器如何工作

在真实部署中,多个组件协同工作。点击「下一条消息」看完整的服务流程:

找 Bug 挑战

下面这段代码有一个会导致生成结果严重重复的 Bug。点击你认为有问题的那一行:

找出这段推理代码中的 Bug:

1 for i in range(num_new_tokens):
2 next_token = decode_one_token(model, cur_token, input_pos)
3 new_tokens.append(next_token)
4 input_pos += 1
5 if cur_token == im_end_id: break

工程技巧:长文本分块生成

真实场景中,文本可能很长(如一整篇文章)。Fish Speech 用智能分块策略处理:

Python

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 字节。

遍历每一轮对话。

检查是否超过限制:

说话者数量是否已达上限?

或者字节数是否超过限制?

如果超了,当前块结束。

保存当前块,开始新的一块。

新块从当前轮次开始。

返回所有分好块的结果。

i
为什么分块很重要?

模型有最大序列长度限制(如 2048 Token)。超过限制就生成不了。分块策略保证了每一块都在模型能处理的范围内。同时,每块生成完后,结果会作为「历史记忆」加入下一块的输入——保持声音的连贯性。

最终检验

场景题

你需要为一个播客应用生成一段 10 分钟的对话音频,包含 3 个说话者。你会如何设计调用方案?

声音克隆时,参考音频通过什么方式传递给模型?

Fish Speech 的语义 Token 空间有多大?