先來聽這個

音訊①:用自製 TTS 唸出「こんにちは」

有沒有覺得有點耳熟?

  ∧_∧
 ( ´∀`) < こんにちは
 (    )
  | | |
 (__)_)

沒錯,是モナー。

姑且說,牠本人(?)是想表達「こんにちは」的。應該是聽得出來,但也就大概如此。

這個聲音完全沒有使用錄音。是把從「あいうえお」到「ん」的 104 個音,一個一個用程式做出來,再從字典中選出來接起來的。

相對地,這個聲音是用名為 piper-plus 的語音合成引擎做出來的。同樣是「こんにちは」。

音訊②:用 piper-plus 做出來的

同樣是「從文字到聲音」,差距卻這麼大。

中間到底發生了什麼事呢?

這篇是寫給這樣的人看的

  • 聽過 TTS 這個詞,但說不出裡面到底在做什麼
  • 能大概說出 AI 聲音為什麼自然,卻講不清楚原因
  • 覺得語音處理很多數學式,所以一直敬而遠之
  • 知道呼叫函式庫就能跑,但很在意那一行背後到底做了什麼

只要懂 Python 基本文法就能讀。這裡不會用到數學與訊號處理的知識。

本文會談到的範圍

先從我一開始做的那個モナー聲音,為什麼聽起來那麼斷裂開始找原因。接著再看看 piper-plus 是怎麼把剩下的差異補上的。

要追的問題有 3 個。

① 只把聲音排起來,為什麼還不像人聲
② 要修正什麼,才會比較接近人聲
③ 即使如此還有的差異是什麼。AI 又是怎麼解決它的

這不是一篇完整涵蓋語音合成規格的文章。只聚焦在一開始最需要先掌握的機制。

關於 TTS 這個詞

TTS(Text-to-Speech)指的是把文字輸入後輸出聲音的機制。中文通常稱為語音合成。

前面的モナー也是,輸入文字後就有聲音輸出,所以這也是很正式的 TTS。雖然是モナー。

① 只把聲音排起來,為什麼還不像人聲

從最單純的做法開始

如果要從文字做出聲音,你第一個想到的會是什麼?

最直覺的方法,我想大概是這樣。

先把從「あいうえお」到「ん」的每個音都準備好音訊檔
↓
收到「こんにちは」時,挑出「こ・ん・に・ち・わ」的檔案
↓
依序接起來變成一條

前面的聲音就是這樣做的。總共用了 104 個音。原本想說「50 音」就好了,但加上濁音、半濁音,以及像「きゃ」這類拗音之後,就變成 104 個了。

像「こんにちは」這樣的句子,會從字典裡選出 5 個音。

こんにちは → こ / ん / に / ち / わ

最後是「わ」而不是「は」,是因為字典裡有把助詞的「は」對應成讀音「わ」。接著只要把這 5 個檔案順序接起來就好。長度變成 1.54 秒。

文字確實變成聲音了。不過,還是モナー。

為了看出到底發生了什麼,把聲音視覺化


用耳朵可以聽出來「很斷」。但到底斷在哪裡,光靠耳朵不容易知道。

所以把聲音拿來看。這裡用的是頻譜圖。縱軸是頻率,橫軸是時間,亮的地方表示那個頻率成分比較強。就是這樣的圖。

為了先理解怎麼看,先來錄一下「あいうえお」。

音訊③:あいうえお

あいうえお的頻譜圖

有兩條亮帶,一條在下方,一條在上方。每當母音改變時,這兩條帶子上下移動。看得出來嗎?

亮帶代表的是「嘴型」

為什麼會有兩條帶子會動?這和人發聲的方式有關。

發聲需要兩個東西:

材料   位於喉嚨深處的聲帶,會被氣流震動,產生「嗡」的聲音
加工   這個聲音通過口腔時,嘴巴與喉嚨的形狀會把特定頻率加強

在加工時被加強的位置,就是頻譜圖上的亮帶。這些帶子會從低到高依序編號為 F1、F2……雖然有很多條,但決定母音的主要是下面兩條,也就是 F1 和 F2。 之後我們只追這兩個。

關鍵在這裡:材料部分不會因為母音改變而改變。 不管你說的是「あ」還是「い」,聲帶發出的其實是同樣的聲音。改變的只有加工方式。所以母音的差異,會變成共振峰位置的差異。

F1 與 F2 到底是什麼,動一動嘴巴就知道了。請不要發出聲音,只用嘴型做一次「あ」和「い」。

「あ」的時候嘴巴張得很大,舌頭在後面。「い」的時候嘴巴變窄,舌頭往前。

F1 = 嘴巴張得多開      張越大,數值越高
F2 = 舌頭往前伸多少    舌頭越往前,數值越高

「あ」的 F1 高、F2 低;「い」則相反。可以把 F1 和 F2 想成是嘴巴張開程度與舌頭位置的數值化。

用耳朵確認會比較快,所以先從材料的聲音開始一點一點加上去。先看沒有任何加工、只有聲帶本身的聲音。

音訊④:作為材料的聲音(只有聲帶聲,沒有加工)

像蜂鳴器一樣的聲音。到這一步還不是母音。

在這上面只加一個加工,也就是 F1。

音訊⑤:只加了 F1 的聲音

有點悶悶的。還不是母音。

再把 F2 以上也加上去。

音訊⑥:把共振峰全部加上的聲音

變成「あ」了。材料的聲音從音訊④開始完全沒變,只是把會被加強的位置加上去了。

那如果改變這些位置的數值呢?

# 「あ」   嘴巴張大,舌頭靠後
F1 = 800
F2 = 1200

# 「い」   嘴巴縮小,舌頭往前
F1 = 300
F2 = 2300

只改了這兩個。上面的帶子保持不動。

音訊⑦:只是改了數值的聲音

變成「い」了。

母音的差異,幾乎就是由 F1 與 F2 的位置決定的。

母音F1 [Hz]F2 [Hz]あ8001200い3002300う3501300え5001900お500900### 數值在移動的途中也聽聽看

如果不是一下切換,而是花 2.5 秒連續移動,會變成什麼樣子?這點對後面很重要。

音訊⑧:從「あ」到「い」

從「あ」到「い」,F1 與 F2 持續移動

圖中兩條線從左到右逐漸分開。下面那條線從 800Hz 降到 300Hz,上面那條線從 1200Hz 升到 2300Hz。

聲音本身也應該是從「あ」平順地變到「い」,中間沒有明顯斷點。

也就是說,頻譜圖其實是在記錄嘴型隨時間如何變化。這樣就能找出原因了。

把モナー的聲音拍下來看看

把一開始的聲音,和等等要修正的聲音放在一起比較。

接起來的版本與一口氣合成版本的比較

上面是把 104 個音接起來的版本,下面是修正後的版本。兩個都是同樣的「こんにちは」。

請看上面。5 個區塊是斷開來排的,而且每個區塊裡的帶子幾乎都水平平行。

下面則沒有斷點。帶子是斜著連起來的。

為了能一邊看圖一邊聽,兩個版本都放在這裡。

音訊①(重掲):把 104 個音接起來的版本(上圖)

音訊⑨:一口氣合成的版本(下圖)

斜線是嘴巴在移動的痕跡

看下方圖的 0.3 秒左右。線是斜斜往上升的。

這和剛剛「あ→い」看到的是同一種圖樣。上面的帶子從 1000Hz 移到 2300Hz。

這是從「ん」移到「に」的時候,嘴型正在變化的途中。

人說「んに」時,不會先停在「ん」的嘴型,下一瞬間再瞬間切到「に」的嘴型。舌頭和嘴唇是連續移動的。這個移動軌跡會直接留在聲音裡。

這就叫做過渡

就算把每個音單獨做好再排起來,也不會自然連起來


這就是答案。

即使單獨做出「こ」、單獨做出「ん」,那些檔案裡也沒有過渡。因為過渡只存在於兩個音之間的邊界。

也就是說,在把 104 個音一個一個做出來的那一刻起,根本還沒有做出任何過渡。沒做出來的東西,之後再接也不會自己出現。

而且人耳正是靠這些過渡來辨認子音。ba da ga 的差異,幾乎就是母音前那條帶子的移動方式不同。

把過渡拿掉,就會變得難以理解。會變成モナー也是理所當然。

到這裡的整理

共振峰與過渡的整理

目前出現了 3 個概念。

共振峰是由嘴巴與喉嚨形狀所加強的頻率。在頻譜圖上會看到亮帶。從低到高依序叫做 F1、F2。

母音的差異,幾乎就是由 F1 和 F2 在哪裡決定的。

過渡則是從一個音移到下一個音時,嘴巴移動留下的痕跡。它不在音裡,而是在音與音的中間

② 要修正什麼,才會比較接近人聲

原因找到了,開始修正。

不要一個字一個字做,而是一次把整句送進去。

# 修正前:一個字一個字合成,之後再接起來
for kana in ["こ", "ん", "に", "ち", "わ"]:
    save_wav(synthesize(kana))     # → 接起來 → モナー

# 修正後:一次把整句送進去
synthesize("こんにちわ")            # → 過渡會接起來

是同一個函式。沒有多寫任何新的處理。只是改了輸入的單位而已。

為什麼這樣就會產生過渡

產生共振峰的部分長這樣。內容看不懂沒關係,只要看參數型別就好。

def _resonator(x: np.ndarray, freq: np.ndarray, bw: np.ndarray) -> np.ndarray:
    ...

freq 不是單一數值,而是陣列。也就是說,共振峰的位置可以逐取樣點移動。剛剛在「あ→い」的連續變化時,用到的就是這個特性。

當整句一起送進去時,內部會根據音素排列,建立成一條連續的軌跡。

# 在音素中心放入共振峰的目標值,中間用內插補上
hold = 0.34 if phone.kind == "vowel" else 0.42
for at in (t + duration * hold, t + duration * (1.0 - hold)):
    f1.append((at, phone.formants[0]))
    f2.append((at, phone.formants[1]))
    f3.append((at, phone.formants[2]))

每個音素只會放上「在這個時間點應該是這個形狀」這樣的目標。目標與目標之間,會用內插自動補起來。

這個內插,正是過渡本身。

以前一個音一個音合成時,每個檔案只帶著自己的目標。因為沒有人去補中間那段,所以帶子才會平行。

拿來對照聽聽看

當整句一起送進去時,內部會把「こんにちは」拆成這樣。

こんにちわ → k o N n i C i w a

總共有 9 個。這就是把「こんにちは」拆到最小音粒的結果。C 代表「ち」的子音,N 代表「ん」。

請注意「ち」雖然是一個字,卻被拆成 Ci 兩個部分。它不是依照文字,而是依照實際發出的聲音來拆。這一顆一顆的單位就叫做音素

把剛剛圖裡一起聽的兩個版本再放一次。

音訊①(重掲):接起來的版本

音訊⑨(重掲):一口氣合成的版本

長度也變了。從 1.54 秒變成 1.05 秒,縮短了 32%。

接起來的版本中,每個音都有自己獨立的起音與衰減。5 個音的起音與衰減全部都保留下來,所以會顯得拖長。一口氣做出來時,這些重疊就消失了。

再看另一組。

音訊⑩:ありがとう(接起來的版本)

音訊⑪:ありがとう(一口氣版)

不是把聲音排起來,而是把嘴巴的動作接起來

TTS 做的不是聲音的排列。它做的是會隨時間變化的嘴型

也可以換句話說:音素不是素材,而是通過點。

這種做法稱為共振峰合成,1980 年代以前的 TTS 已經做到這一步了。那個年代會說話的玩具聲音,正是這種聲音。

③ 即使如此還有的差異是什麼。AI 又是怎麼解決的

字是聽得懂了,但和一開始的 piper-plus 相比,還差得很遠。

還缺什麼,直接列出來。

觀點自製版本piper-plus可讀的文字只有かな,漢字讀不了連漢字也能一起讀音長固定值(例如「あ」永遠是 130ms)依上下文決定語調與內容無關的固定曲線會隨內容改變前後音的影響忽略掉(k 永遠是同一個值)會反映出聲音的個人特色沒有。不是任何人的聲音,而是某個特定說話者的聲音來看語調的例子。日文有高低重音,相同的假名只要高低不同,意思就會變。像「橋」和「箸」、「雨」和「飴」。這次的實作用了和輸入無關的固定曲線,所以兩者會變成同一個聲音。

這 5 項共通的地方在於:全部都是「要怎麼決定正確數值」的問題。

由 AI 來做的日文語音合成:piper-plus

這就是前面音訊②所使用的引擎。它可以用已訓練好的 AI 模型來說日文,而且可以在自己的電腦上執行。它是從 Piper 這個語音合成引擎衍生出來的版本。

整體來說可分成兩個階段。

文章
↓
【前半】把文字轉成音素      ← 由各語言的解析器負責
↓
音素序列
↓
【後半】由音素生成波形      ← 由訓練好的模型負責
↓
語音

實際跑看看。先在本機做出「こんにちは」,確認會輸出跟一開始聽到的相同聲音。

pip install piper-plus

python -m piper --model ja_JP-tsukuyomi-chan-medium \
  --length-scale 1.2 --noise-scale 0.5 \
  -f konnichiwa.wav "こんにちは"

指定了 3 個東西。

  • --model:要使用的聲音。ja_JP-tsukuyomi-chan-medium 是日文模型
  • --length-scale:發話長度。數值越大,說得越慢
  • --noise-scale:生成時的變動幅度

第一次執行時,模型會自動下載。副檔名是 .onnx,是訓練好的模型存放格式。和它是用 PyTorch 還是其他框架訓練出來的無關,可以用通用格式直接執行。

輸出的 konnichiwa.wav 就是前面的聲音。接下來要看裡面怎麼運作,所以先再放一次。

音訊②(重掲):用 piper-plus 做出來的

輸出的 WAV 是 16bit PCM 單聲道,和自製版本相同格式。模型內容雖然不同,但輸出端是一樣的。

前半:音素化是怎麼做的

先看前半,把文字轉成音素的部分。第 2 章裡把「こんにちわ」拆成 k o N n i C i w a,做的就是同樣的事情。

對日文來說,這部分其實不算輕。要決定漢字怎麼讀、把助詞的「は」讀成「わ」、判斷重音位置。自製版本只用手寫字典做了最低限度的處理。

piper-plus 在這裡使用的是 OpenJTalk。安裝時的相依套件裡就能看到它。

Collecting pyopenjtalk-plus>=0.4.1.post8 (from piper-plus)

OpenJTalk 是日文專用的語言解析器,可以處理漢字讀音與重音。

也就是說,piper-plus 自己做的,是前半段「把文字轉成音素」的部分。後半段「從音素生成波形」則沿用其衍生來源 Piper 的做法。

只要替換前半段,就能支援更多語言。 前後分離的優點就在這裡。

後半:長度與語調是誰決定的

自製版本把「あ」直接固定成 130ms。訓練時則是這樣做的。

先把語音資料和音素序列對齊,找出波形中哪一段屬於哪個音素。這叫做對齊

音素列   k    o      N      n   i
波形     ├──┤├────┤├────┤├─┤├───┤
長度    55ms  120ms  95ms  40ms 110ms

一旦對齊成功,就能取出每個音素實際持續了幾毫秒。蒐集幾萬句之後,就會得到一大堆資料,像是「這個音素在這種前後文、這個句子位置時,持續了幾毫秒」。接著只要學會預測這件事就好。

同樣從對齊結果也能取出音高,所以語調也是用類似方式學出來的。「はし」如果是「橋」,就是低到高;如果是「箸」,就是高到低。這種差異會連同上下文一起被學起來。

另外,訓練資料裡不會事先貼好切點標籤。人手切幾萬句太不切實際,所以要讓模型自己找。唯一的線索是「音素是按照順序發音的」。因為順序固定,接下來只要有效率地找出在哪裡切就好。

後半:波形是怎麼做出來的

這裡就是最大的差異。

自製版本做的是把聲道用數學模型表示出來。也就是先假設「聲音是聲帶的聲音通過 3 條共振峰後形成的」,再把那個結構寫進程式。

voiced = _resonator(source, f1, bw1)   # 第 1 共振峰
voiced = _resonator(voiced, f2, ...)   # 第 2 共振峰
voiced = _resonator(voiced, f3, ...)   # 第 3 共振峰

訓練好的模型不會去建立這種結構。

它沒有寫出聲道到底有幾個共振、聲帶波形長什麼樣子。取而代之的是準備一個擁有大量參數的函式,透過調整參數,去重現訓練資料中的波形。

自製版      由人先決定「聲音應該長這樣」,再把數值塞進公式裡
訓練模型    不先決定結構,而是調整參數,讓它能重現資料中的波形

用 3 條共振峰表示的聲道,只能算是人聲「大概的樣子」。真實的人聲還包含很多特徵,例如氣息混入的方式、聲帶閉合的習慣、鼻腔共鳴的比例等等。這些不可能全部寫出來。把寫出來這件事停掉,直接從資料中複製出來的,就是訓練好的模型。

piper-plus 用的模型設計叫做 VITS。推論時流程如下。

音素序列
↓ Text Encoder        把每個音素變成特徵向量(前後音素資訊會互相混合)
↓ Duration Predictor  決定每個音素要延伸成幾個 frame,再把它拉長
↓ Flow                轉成作為波形「種子」的資料
↓ Decoder             把種子展開成波形
語音波形

最後的 Decoder,功能就相當於自製版本裡那 3 條共振峰所做的事。

還有一點。同一段文字對應的聲音不是唯一的。慢慢說的「こんにちは」和開朗地說的「こんにちは」,訓練資料裡都會有。如果硬要學成唯一對應,最後只會變成平均值,結果發出來的是一個模糊、哪裡都不像的聲音。因此 VITS 把聲音當成分布來處理,生成時從中抽出一個。就算同一句話做兩次,也不會完全一樣。

到這裡的整理

piper-plus 前半與後半的整理

我們把 piper-plus 的內部拆成前半和後半來看。

前半負責音素化的是 OpenJTalk。它把文字變成音素序列;如果用自製版本來比喻,就是手寫讀音字典。

後半負責的是訓練好的模型。它會決定每個音素多長、加上語調,並輸出波形。這三件事都使用了從真人發話資料學來的數值。

自製版本則是把這些數值全部寫在程式表格裡。最大的差別其實只有一個:這些數值到底是誰決定的。

總結

文章整體總結

對前面追的 3 個問題,各用一句話回答。

① 只把聲音排起來,為什麼還不像人聲
因為沒有過渡。過渡只存在於音與音之間,在一個個單獨做音時根本不會出現。

② 要修正什麼,才會比較接近人聲
不要一個字一個字做,改成一次把整句送進去。這甚至不需要新增任何程式碼。

③ 即使如此還有的差異是什麼。AI 又是怎麼解決的
差別在於「正確數值由誰來決定」。人不再手寫,而是讓模型從發話資料中學出來,這就是現在的 TTS。


我們平常說話時,根本不會意識到字與字之間發生了什麼。嘴巴怎麼動、延長了幾毫秒、哪裡把音調拉高,沒有人會去數,也不會記住。

但要讓機器說話,這些都必須變成數字。 這篇文章做的,就是那個翻譯工作。

能手寫的數值與不能手寫的數值

104 個音的固定值,還能用手寫出來。寫不出來的,是會依條件每次改變的部分。把那些部分交給人不再手寫的,就是我們現在每天聽到的聲音。

前面的モナー,和那個自然的聲音,差的就是誰來決定數字

最後:如果你覺得這篇文章有趣

這次的文章,從自己動手做的角度,一步一步確認了平常不經意使用的語音合成機制。

Sapeet 很重視這種做法:深入理解 AI 與演算法的原理,並實際動手,把它們連到產品與業務之中。

為了讓大家更了解 Sapeet 的技術與開發氛圍,我們也將舉辦交流活動「Open Sapeet!」。

2026 年 9 月 11 日(五)19:30 起,將由包括 Sapeet 工程師在內的員工登台,聊聊 AI、產品開發,以及未來的工作方式。也許還會談到像本文一樣「AI 裡面到底發生了什麼」的內容。

演講之後,還準備了可以邊吃輕食與喝飲料邊輕鬆交流的時間。

如果你想窺看新創團隊的開發現場,或想了解 Sapeet 的氛圍,歡迎隨時來玩。

活動詳情與報名請見
https://connpass.com/event/398374/

  ∧_∧
 ( ´∀`) < マターリ等你喔
 (    )
  | | |
 (__)_)

參考資料

  • monar-tts — 這篇文章中製作的 TTS 程式碼與 104 個音。只要 pip install numpy 就能執行
  • piper-plus — 文章中使用的引擎。MIT 授權。PyPI 可用 pip install piper-plus
    • 日文模型使用的是 ja_JP-tsukuyomi-chan-medium。語音模型的授權請確認發布來源的說明
  • Piper(OHF-Voice/piper1-gpl) — piper-plus 的衍生來源。本體為 GPL-3.0
  • OpenJTalk — piper-plus 用來做日文音素化的解析器
  • VITS(Kim et al., 2021) — Monotonic Alignment Search、Flow、機率式長度預測都出現在這篇論文中
  • HiFi-GAN(Kong et al., 2020) — VITS 的 Decoder 所採用的波形生成方式

原文出處:https://qiita.com/taguchi_sapeet/items/f2da1003b168b52f5770


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝10   ❤️4
307
🥈
我愛JS
📝1   💬4   ❤️1
58
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登