Skip to content

架构概览

HayaKoe 是接收日语文本并生成 WAV 波形的引擎。内部并非由单个"大模型"一次性处理,而是 分为多个阶段的流水线,各有分工。

本页面按顺序跟踪一段输入文本从进入到变成语音波形的过程,逐步说明 每个阶段发生了什么

如果对术语不熟悉,建议先快速浏览 术语表 后再回来。

完整流水线

输入文本 (日语)

  ├─ 1. 句子分割 (按标点)

  ├─ 每个句子:
  │    ├─ 2. G2P              — 文本 → 音素序列 + 韵律
  │    ├─ 3. BERT             — 句子级上下文嵌入 (说话人公用)
  │    └─ 4. Synthesizer      — 音素 + BERT → 句子级波形
  │         ├─ 4-1. Text Encoder
  │         ├─ 4-2. Duration Predictor
  │         ├─ 4-3. Flow
  │         └─ 4-4. Decoder

  ├─ 5. 句子边界 pause       — Duration Predictor 复用

  └─ 6. 波形拼接 → 最终 WAV

下面逐步跟踪此流程。

逐步跟踪

1. 句子分割

输入文本首先被 按句子分割。以日语标点()、ASCII 标点(!?)和换行(\n)为分隔,之后所有处理都 逐句独立 进行。

HayaKoe 不将句子整体送入而是分割的原因是 质量考量。长句子整体合成时韵律会模糊(prosody 不稳定),或者 BERT 捕获的上下文过长导致某些音节被过度强调。按句子分割后每个句子都能保证稳定的韵律。

有两个权衡。

第一,原始模型在通篇合成中自然生成的 句间停顿 (pause) 因分割而丢失。此问题在后续 5. 句子边界 pause 步骤中通过复用 Duration Predictor 来解决。

第二,在 GPU 路径中分割会导致速度损失。GPU 合成单个句子的时间本来就很短。分割后需要按句子数重复这个短暂的合成,还要额外增加一次计算句间 pause 长度的过程。结果总时间比整体合成更长。

HayaKoe 判断质量收益足以证明这个代价,因此将分割作为默认行为。在 CPU 路径中相反,整体合成长句的成本要高得多,分割反而 对速度也有益

2. G2P — 文本 → 音素序列 + 韵律

接收各句子后首先要做的是 将文字转换为发音。HayaKoe 将日语 G2P 委托给 pyopenjtalk

G2P 处理的不止一两件事。汉字读法(天気てんき)、长音规则、连音 (sandhi)、声调类型 (pitch accent) 决定等日语特有规则全部在此解决。输出不是简单的音素列表,而是 音素序列 + 韵律信息 一对。

此步骤完全在 CPU · Python 上运行,与模型推理无关。pyopenjtalk 的内部词典文件已打包在 wheel 中,无需网络连接即可运行(→ OpenJTalk 词典打包)。

3. BERT — 上下文嵌入

独立于 G2P,原文文本本身 被送入 DeBERTa BERT 获取句子上下文嵌入。每个 token 得到一个包含"这个词在此句中扮演什么角色"信息的向量。

BERT 特征直接反映到后续 Synthesizer 的合成质量中。即使音素序列相同,BERT 也能根据上下文产生不同的韵律·重音。

例如同样的 そうですね 回应,如果前面是强烈主张则合成为确信的附和语气,如果前面是犹豫则合成为带有余韵的共感语气才自然。G2P 只看音素序列无法区分这两种情况,需要 BERT 将周围上下文信息压缩后传给 Synthesizer 才能将这种差异反映到合成中。

这就是 Bert-VITS2 系列相比原始 VITS 能产出更自然发话的核心机制。

重要的结构特征是 BERT 是所有说话人公用的。BERT 占全部模型参数的约 84%(~329 M,FP32 约 1.2 GB),是 体积最大的模块。而每个说话人不同的 Synthesizer 为 63 M(约 251 MB)规模。

得益于这种不对称,BERT 只加载一次让所有说话人共享的结构至关重要。即使同时服务 N 个说话人,BERT 也只加载 1 次,每个说话人仅增加 251 MB。这就是多说话人服务中内存不会线性暴涨的原因。

此外,处理多句时会将多个句子 打包成一个批次仅调用 1 次 BERT。GPU 上 kernel launch overhead 减少,收益随句子数量成比例增大;CPU 上收益·损失都不大,为了后端间代码统一保持相同路径(→ BERT GPU 保持 & 批量推理)。

4. Synthesizer — 音素 + BERT → 波形

这是实际生成语音的部分。输入是 (音素序列, 韵律, BERT 嵌入, 风格向量),输出是 该句子份量的波形

其中 风格向量 是在 .load(speaker) 时从该说话人的 style_vectors.npy 一同加载的值,是将说话人的说话方式·语气特征压缩为一个向量的表征。HayaKoe 目前为简化仅使用 Neutral 风格(→ 术语表 — Style Vector),每次合成调用都直接注入此值。

Synthesizer 内部分为四个子模块,文本信息按此顺序流过并转换为波形。

4-1. Text Encoder — 音素到向量空间

Transformer 编码器结构,将每个音素嵌入为 192 维隐藏向量。BERT 特征在此处与音素级嵌入结合,句子上下文首次注入到音素级信息中。

输出 shape 为 (音素数, 192),作为后续两个阶段的共享输入。

4-2. Duration Predictor — 各音素发音多少帧

Stochastic Duration Predictor (SDP) 对每个音素从概率分布中采样决定"发音多少帧"。如"安"5 帧、"宁"4 帧。由于是概率采样,即使同一句话每次调用韵律·速度也略有不同。

此步骤中音素序列在 时间轴上被展开。可以理解为每个音素按其持续长度重复复制后拼接。结果长度与最终音频的帧数直接相关。

4-3. Flow — 文本嵌入 → 音频 latent

经过 Normalizing Flow 的反向变换,将 Text Encoder 生成的嵌入转移到 音频 latent 空间的 z 向量。这个 z 包含"应该是什么声音"的低维表征,下一个 Decoder 接收它生成实际波形。

Flow 是可逆神经网络,训练时走正方向(正确音频 latent → 文本嵌入空间),推理时走其反方向。

4-4. Decoder — latent z → 波形

HiFi-GAN 系列的 Decoder,接收 latent z 并通过 ConvTranspose 上采样和残差块 (ResBlock) 生成 时域的实际波形

Synthesizer 子模块中运算量最大,推理时间的大部分都在此消耗。CPU 路径导出为 ONNX 时此部分也是主要优化对象。

经过这四个步骤,该句子的 44.1 kHz 波形完成。

5. 句子边界 pause 预测

这是解决 第 1 步 中提到的"句间停顿丢失"问题的步骤。

HayaKoe 在原本用途之外再次复用 Duration Predictor。将完整原文文本仅通过 Text Encoder + Duration Predictor,获取 .!? 标点位置的帧数,转换为秒单位的 pause 时间。跳过 Flow 和 Decoder,因此额外成本很低。

结果不是固定 80 ms 静音,而是生成 根据句子长度·结构自然变动的 pause。详情请参见 句子边界 pause — Duration Predictor

6. 波形拼接 → 最终输出

最后将各句子的波形按顺序拼接,在句子之间插入 第 5 步 预测长度的静音样本。

最终输出是 44.1 kHz · 单声道 float32 波形(NumPy array)。调用 .save() 即可保存为 WAV 文件。

使用 Streaming API (astream()) 时每完成一个句子立即 yield,因此可以在全部合成完成前开始播放。在长文本中能 大幅缩短到首条语音的延迟(→ 流式传输示例)。

延伸阅读