语音克隆、.s2voice 与对话模板

本章把”参考音频如何变成模型能吃的输入”这条链路讲完整,涉及三个文件:s2_codec.cpp 的 encode(第 08 章已分析)、s2_voice.cpp 的声音档案、s2_prompt.cpp 的 prompt 构造。

1. 语音克隆的完整链路

参考音频文件(WAV/MP3)
 │  audio_read:解码 + 下混单声道 + 必要时重采样到 44.1kHz
 ▼
AudioData(float, 44.1kHz)
 │  codec.encode:两图卷积编码 + 余弦VQ + 9级残差VQ
 ▼
prompt_codes(10 × T_prompt 整数码矩阵) + T_prompt
 │
 ├─(可选)保存为 .s2voice 档案,供以后免编码复用
 │
 ▼
build_prompt:文本 + transcript + prompt_codes → 11 行 PromptTensor
 │
 ▼
generate → Slow-AR prefill

入口在 Pipeline 的 resolve_reference_prompt_locked():要么现场编码参考音频,要么从 .s2voice 档案载入已编码结果。”locked” 表示该方法在持有流水线互斥锁的上下文中调用。

2. build_prompt():两种模板

build_prompt() 构造第 02 章介绍的 (num_codebooks+1) × T PromptTensor。它根据是否带参考音频拼两套不同的对话模板。

2.1 带参考音频(克隆模式)

判定条件很严格(:23):必须同时有 prompt_codes、T_prompt > 0、且 prompt_text(参考音频的转写文本)非空——三者缺一就退化为无参考模式。

模板分三段拼装,sys_pre 在参考码之前、sys_post 在其后:

sys_pre:
  <|im_start|>system\n
  convert the provided text to speech reference to the following:\n\nText:\n
  <|speaker:0|>                         (若转写文本中没有 <|speaker: 标签)
  <prompt_text(参考音频的转写)>
  \n\nSpeech:\n

[参考码段:T_prompt 帧]

sys_post:
  <|im_end|>\n
  <|im_start|>user\n<目标文本><|im_end|>\n
  <|im_start|>assistant\n<|voice|>

字符串内容见 :34-52。设计意图:模型在训练时见过”Text + Speech”成对的转换任务,推理时先给一段已知的文本↔语音对作为示范,再让它对新文本产出同样风格的语音——这本质上是一种上下文学习(in-context learning)式的克隆,不需要单独的说话人嵌入。

<|speaker:0|> 的处理有个细节(:24、:37-39):如果转写文本本身已含说话人标签,就不再重复插入,避免多说话人参考时标签错乱。

2.2 无参考(默认音色模式)

不带参考时模板简单得多(:58-71):

<|im_start|>system\nconvert the provided text to speech<|im_end|>\n
<|im_start|>user\n<文本><|im_end|>\n
<|im_start|>assistant\n<|voice|>

此时没有参考码段,模型使用其从多说话人数据中学到的默认发声方式。

2.3 数据布局

参考码段写入矩阵时区分语义行与码本行(:86-96):

第 0 行(语义流):prompt_codes[t] + semantic_begin   ← 码本索引抬升到词表中的语义区间
第 cb+1 行(cb=0..9):prompt_codes[cb*T_prompt+t]     ← 原始码,不偏移

而 sys_pre、sys_post 的文本 token 只写在第 0 行(:81-83、:98-100),其他行留 0 并由语义掩码屏蔽。这保证文本段和音频段在统一的 11 行布局下各自就位。总长度在 :74 一次算出:sys_pre + (T_prompt) + sys_post。

3. .s2voice:二进制声音档案

每次合成前都现场编码参考音频会浪费时间(编码图构建 + 计算不便宜)。s2.cpp 定义了一个紧凑的二进制档案格式,由 s2_voice.cpp 实现,把编码结果连同元数据固化下来。

3.1 文件格式

文件头与字段(save()):

偏移  字段                  类型
0     MAGIC                 8 字节:"S2VOICE\0"
8     version               uint32:1
12    num_codebooks         int32
16    T_prompt              int32
20    sample_rate           int32
24    codebook_size         int32
28    transcript_len        uint64(含结尾 NUL)
36    codes_size            uint64(字节数)
44    transcript            UTF-8 文本 + '\0'
..    codes                 int32 数组(10 × T_prompt,与 codec 输出同布局)

魔数常量在 :12,版本号 :13。

3.2 加载时的完整性校验

load() 不是简单反序列化,而是逐层设防:

  • 魔数不匹配 → “invalid voice profile magic”(:50-52);
  • 版本号 != 1 → “unsupported version”(:56-58);
  • transcript 长度为 0、或读出的文本不是 NUL 结尾 → 报错(:72-80),防止把脏数据当字符串;
  • 读完后检查流状态,截断文件 → “truncated”(:87-89)。

3.3 兼容性检查

is_compatible() 在载入档案后比对三项:码本数、码本大小、采样率全部一致才允许使用。这防止把 A 模型(比如不同码本配置)的档案错配到 B 模型上——码布局不一致会产生无声或噪声。检查在 Pipeline 解析参考时执行,不通过即报错返回。

4. VoiceProfileManager:按 id 管理档案

VoiceProfileManager 给每个声音一个字符串 id,用 <id>.s2voice 的文件名管理:

  • 存储目录可通过 --voice-dir 设置,默认 ./voices;取路径时若目录不存在会自动创建(:105-111);
  • 支持 save / load / remove / list;list 靠扫描 .s2voice 扩展名(:129-141);
  • CLI 的 --voice <id> 载入、--save-voice 在本次克隆后保存、--list-voices 列举(main.cpp:163-166)。

典型用法:

# 第一次:用参考音频建立档案
./s2 -m model.gguf --prompt-audio ref.wav --prompt-text "参考音频转写" \
     --voice alice --save-voice --text "占位" -o /tmp/x.wav

# 以后:直接用 id,无需再提供参考音频
./s2 -m model.gguf --voice alice --text "你好" -o out.wav

5. 参考音频本身的处理

进入 codec 之前,s2_audio.cpp 负责音频前处理(与输出共用一个文件):

  • audio_read():先按 WAV 解,失败再按 MP3 解;多声道按下述规则下混为单声道;
  • 采样率不符时用 audio_resample() 线性重采样到 44.1 kHz;
  • 参考音频的长度和信噪比直接决定克隆质量——README 明确指出这是已知敏感点(README.md:565)。

6. 小结

声音克隆在 s2.cpp 里不是额外模型,而是“编一段已知码放进 prompt”的上下文技巧:codec 负责音频→码、.s2voice 负责码的持久化与兼容校验、build_prompt 负责把文本与码按 11 行布局拼成对话模板。理解了输入如何构造,第 10 章就可以看整个流水线如何把这些组件组装起来,并实现低延迟的流式合成。

关键文件

位置 职责
s2_prompt.cpp:5 build_prompt 模板构造
s2_prompt.cpp:23 克隆模式判定
s2_voice.cpp:15 .s2voice 保存
s2_voice.cpp:42 .s2voice 加载与校验
s2_voice.cpp:94 码本兼容性检查
s2_voice.cpp:101 VoiceProfileManager
include/s2_prompt.h PromptTensor 定义

This site uses Just the Docs, a documentation theme for Jekyll.