架構概覽
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,因此可以在全部合成完成前開始播放。在長文本中能 大幅縮短到首條語音的延遲(→ 串流傳輸範例)。
延伸閱讀
- 後端選擇 (CPU vs GPU) 及
load·prepare·generate生命週期 — 後端選擇 - 各階段的最佳化細節 — ONNX 最佳化 · BERT GPU 保持 & 批次推論
- 句子邊界 pause 的實作細節 — 句子邊界 pause
