まず、これを聴いてください
音声①:自作したTTSで「こんにちは」
聞き覚えのある感じ、しませんか。
∧_∧
( ´∀`) < こんにちは
( )
| | |
(__)_)
そう、モナーです。
一応、本人(?)は「こんにちは」と言っているつもりです。言われれば分かる、くらいでしょうか。
この音、録音は一切使っていません。あいうえおから「ん」まで104音を1つずつコードで作り、辞書から選んで繋いだものです。
一方で、こちらは piper-plus という音声合成エンジンで作った音です。同じ「こんにちは」です。
音声②:piper-plusで作ったもの
同じ「文字から音へ」なのに、この差です。
間には何があるのでしょうか。
こんな人に向けて書きました
- TTSという言葉は聞いたことがあるが、中で何が起きているかは説明できない
- AIの音声が自然に聞こえる理由を、なんとなくでしか言えない
- 音声処理は数式が多そうで、避けてきた
- ライブラリを呼べば動くのは知っているが、その1行の中身が気になっている
Pythonの基本文法が分かれば読めます。数学と信号処理の知識は使いません。
この記事で扱う範囲
冒頭の私が作成したモナー声が、なぜあそこまでぶつ切りに聞こえるのか。その原因を突き止めます。そのあと、それでも残る差をpiper-plusがどう埋めているのかを見ます。
追いかける問いは3つです。
① 音を並べただけでは、なぜ声にならないのか
② 何を直せば声に近づくのか
③ それでも残る差は何か。AIはそこをどう解いているのか
音声合成の仕様を網羅する記事ではありません。最初に押さえておきたい仕組みに絞ります。
TTSという言葉について
TTS(Text-to-Speech)は、文字を渡すと音声が返ってくる仕組みのことです。日本語では音声合成と呼びます。
冒頭のモナーも、文字を入れて音声が出てきたので、これで立派なTTSです。モナーですが。
① 音を並べただけでは、なぜ声にならないのか
いちばん単純な作り方から始める
文字から音声を作れと言われたら、まず何を思いつくでしょうか。
一番素直なのは、こういう方法だと思います。
あいうえおから「ん」まで、1音ずつ音声ファイルを用意しておく
↓
「こんにちは」が来たら、こ・ん・に・ち・わ のファイルを選ぶ
↓
順番に繋いで1本にする
実際にやったのが冒頭の音です。使ったのは104音です。「50音」と言いたいところですが、濁音と半濁音、それに「きゃ」のような拗音を足すと104になりました。
「こんにちは」なら、辞書から5つが選ばれます。
こんにちは → こ / ん / に / ち / わ
末尾が「は」ではなく「わ」なのは、助詞の「は」を「わ」と読む対応を辞書に入れてあるからです。あとはこの5つのファイルを順番に繋ぐだけ。長さは1.54秒になりました。
文字は音声になりました。ただ、モナーです。
何が起きているのかを見るために、音を目で見る
耳で「ぶつ切りだ」とは分かります。でも、何がぶつ切りなのかは耳だけでは分かりません。
そこで音を目で見ます。使うのはスペクトログラムという図です。縦軸が周波数、横軸が時間、明るいところがその周波数の強い部分。それだけの図です。
読み方を掴むために、まず「あいうえお」を撮ってみます。
音声③:あいうえお
明るい帯が、下に1本、その上にもう1本あります。母音が変わるたびに、この2本が上下しているのが見えるでしょうか。
明るい帯は「口の形」を表している
なぜ2本の帯が動くのか。人が声を出す仕組みと関係しています。
声を出すには、材料と加工の2つが要ります。
材料 のどの奥にある声帯が、息で震えて「ブー」という音を作る
加工 その音が口の中を通るとき、口と喉の形が、特定の高さだけを強める
加工で強められた位置が、スペクトログラムの明るい帯です。帯は低いほうから順に、F1、F2……と番号が付きます。何本もあるのですが、母音を決めているのは下の2本、F1とF2です。 以降はこの2つだけ追いかけます。
ここが肝心なのですが、材料のほうは母音が変わっても同じです。「あ」と言うときも「い」と言うときも、声帯は同じ音を出しています。変わるのは加工だけ。だから母音の違いは、フォルマントの位置の違いになります。
そのF1とF2が何なのかは、口を動かせば分かります。「あ」と「い」を、声に出さずに口だけで言ってみてください。
「あ」は口が大きく開いて、舌は奥にあります。「い」は口が狭くなって、舌が前に出ます。
F1 = 口をどれくらい開けているか 大きく開けるほど高い
F2 = 舌をどれくらい前に出しているか 前に出すほど高い
「あ」はF1が高くてF2が低い。「い」はその逆です。F1とF2は、口の開き具合と舌の位置を、数字にしたものだと思ってください。
耳で確かめたほうが早いので、材料の音から順に足していきます。まずは加工なしの、声帯の音そのものです。
音声④:材料になる音(声帯の音だけ、加工なし)
ブザーのような音です。この時点では、まだ母音になっていません。
ここに加工を1つ、F1だけを加えます。
音声⑤:F1だけ加えた音
少しこもりました。まだ母音ではありません。
さらにF2から上も加えます。
音声⑥:フォルマントを全部加えた音
「あ」になりました。材料の音は音声④のときから何も変えていません。強める位置を足しただけです。
では、その位置の数値を変えるとどうなるか。
# 「あ」 口を大きく開けて、舌は奥
F1 = 800
F2 = 1200
# 「い」 口を狭めて、舌は前
F1 = 300
F2 = 2300
動かしたのは、この2つだけです。上のほうの帯はそのままにしてあります。
音声⑦:数値を変えただけの音
「い」になりました。
母音の違いは、F1とF2の位置でほぼ決まります。
| 母音 | F1 [Hz] | F2 [Hz] |
|---|---|---|
| あ | 800 | 1200 |
| い | 300 | 2300 |
| う | 350 | 1300 |
| え | 500 | 1900 |
| お | 500 | 900 |
数値を動かしている途中も聴いてみる
一気に切り替えるのではなく、2.5秒かけて連続的に動かすとどうなるか。ここが後の話に効いてきます。
音声⑧:「あ」から「い」へ
図では、2本の線が左から右へ離れていきます。下の線が800Hzから300Hzへ下がり、上の線が1200Hzから2300Hzへ上がっています。
音のほうも、「あ」から「い」へ、境目なく変わっていったはずです。
つまりスペクトログラムは、口の形が時間とともにどう動いたかの記録です。これで原因を探せます。
モナー声を撮ってみる
冒頭の音と、このあと直す音を並べます。
上が104音を繋いだもの、下が直したものです。同じ「こんにちは」です。
上を見てください。5つの塊がぶつ切りに並んでいます。そして塊の中では、帯がほぼ横に平行です。
下は切れ目がありません。帯が斜めに繋がっています。
図と音を並べて確認できるよう、両方を置いておきます。
音声①(再掲):104音を繋いだもの(上の図)
音声⑨:一息で合成したもの(下の図)
斜めの線は、口が動いている跡
下の図の0.3秒あたり。線が斜めに上がっています。
さきほど「あ→い」で見たのと同じ模様です。上の帯が1000Hzから2300Hzへ移動しています。
これは、「ん」から「に」へ移るとき、口の形が動いている途中です。
人が「んに」と言うとき、「ん」の形で止まって、次の瞬間に「に」の形へ瞬間移動したりしません。舌と唇は連続的に動きます。その移動の軌跡が、そのまま音に残ります。
これを渡りと呼びます。
1音ずつ作った音を並べても、滑らかに繋がらない
ここが答えです。
「こ」を単独で作っても、「ん」を単独で作っても、そのファイルの中に渡りは入っていません。渡りは2つの音の境界にしかないからです。
つまり104音を1つずつ作った時点で、渡りは1つも作られていなかったわけです。作られていないものは、あとから繋いでも出てきません。
しかも人の耳は、この渡りを手がかりに子音を聞き分けています。ba da ga の違いは、母音へ向かう帯の動き方の違いでほぼ決まります。
渡りを落とせば聞き取りにくくなる。モナーになるのは当然でした。
ここまでの整理
出てきたのは3つです。
フォルマントは、口と喉の形によって強められる周波数のこと。スペクトログラムでは明るい帯として見えます。低いほうからF1、F2と呼びます。
母音の違いは、F1とF2がどこにあるかでほぼ決まります。
渡りは、ある音から次の音へ移るときに口が動いた跡です。音の中ではなく、音と音のあいだにしかありません。
② 何を直せば声に近づくのか
原因が分かったので、直します。
1文字ずつ作るのをやめて、文全体を一度に渡します。
# 直す前:1文字ずつ合成して、あとで繋ぐ
for kana in ["こ", "ん", "に", "ち", "わ"]:
save_wav(synthesize(kana)) # → 繋ぐ → モナー
# 直したあと:文全体を一度に渡す
synthesize("こんにちわ") # → 渡りが繋がる
同じ関数です。新しい処理は1行も書いていません。渡す単位を変えただけです。
なぜこれで渡りが生まれるのか
フォルマントを作っている部分は、こういう形をしています。中身は読まなくて構いません。引数の型だけ見てください。
def _resonator(x: np.ndarray, freq: np.ndarray, bw: np.ndarray) -> np.ndarray:
...
freqが単一の値ではなく配列です。フォルマントの位置を、1サンプルごとに動かせるようになっています。さきほど「あ→い」を連続的に変化させたときに使ったのが、この性質です。
文全体を渡すと、内部では音素の並びから1本の連続した軌跡が組まれます。
# 音素の中心にフォルマントの目標値を置き、あいだは補間で埋める
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]))
各音素が置くのは「この時刻にはこの形」という目標だけです。目標と目標のあいだは、補間で自動的に埋まります。
この補間が、そのまま渡りになります。
1音ずつ合成していたときは、各ファイルが自分の目標しか持っていませんでした。あいだを埋める相手がいないので、帯が平行になっていたわけです。
聴き比べる
文全体を渡すとき、内部では「こんにちは」がこう分解されます。
こんにちわ → k o N n i C i w a
9個あります。これが「こんにちは」を音の粒まで分けたものです。Cは「ち」の子音、Nは「ん」を表しています。
「ち」が1文字なのにCとiの2つになっている点に注目してください。文字ではなく、実際に出している音で分けています。この1粒ずつを音素と呼びます。
さきほど図と一緒に聴いた2つを、もう一度並べます。
音声①(再掲):繋いだもの
音声⑨(再掲):一息で合成したもの
長さも変わりました。1.54秒から1.05秒へ、32%短くなっています。
繋いだ版では、各音が自分用の立ち上がりと減衰を持っていました。5音ぶんの立ち上がりと減衰が全部残るので間延びします。一息で作ると、その重複が消えます。
もう1組。
音声⑩:ありがとう(繋いだもの)
音声⑪:ありがとう(一息)
音を並べるのではなく、口の動きを繋ぐ
TTSが作っているのは、音の列ではありませんでした。時間とともに変わっていく口の形です。
音素は素材ではなく、通過点だった、と言い換えてもいいと思います。
この考え方はフォルマント合成と呼ばれ、1980年代までのTTSが到達していたところです。あの時代の喋るおもちゃの声が、まさにこの音です。
③ それでも残る差は何か。AIはそこをどう解いているのか
言葉としては聞き取れるようになりました。でも冒頭のpiper-plusには、まだ遠いです。
何が足りないのか、並べます。
| 観点 | 自作したもの | piper-plus |
|---|---|---|
| 読める文字 | かなだけ。漢字は読めない | 漢字も含めて読む |
| 音の長さ | 定数(「あ」は常に130ms) | 文脈から決まる |
| 抑揚 | 内容と無関係な固定カーブ | 内容に応じて変わる |
| 前後の音の影響 | 無視(kは常に同じ値) |
反映される |
| 声の個人性 | 無い。誰の声でもない | 特定の話者の声になる |
抑揚を例に見てみます。日本語にはピッチアクセントがあり、同じ仮名でも高低で意味が変わります。「橋」と「箸」、「雨」と「飴」。今回の実装は入力と無関係な固定カーブを使っているので、どちらも同じ音になります。
5つに共通しているのは、どれも「正しい数値をどう決めるか」の問題だという点です。
AIによる日本語音声合成、piper-plus
冒頭の音声②を作ったエンジンです。学習済みのAIモデルで日本語を喋らせられて、しかも手元のPCで動きます。Piper という音声合成エンジンから派生したものです。
やっていることを大きく分けると2段階になります。
文章
↓
【前半】文字を音素に変換する ← 言語ごとの解析器が担当
↓
音素の並び
↓
【後半】音素から波形を作る ← 学習済みモデルが担当
↓
音声
実際に動かしてみます。手元で「こんにちは」を作って、冒頭で聴いた音と同じものが出てくることを確認します。
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のモノラルで、自作したものと同じ形式です。モデルの中身が違っても、出口は変わりません。
前半:音素化はどうしているのか
前半、文字を音素に変換する部分です。②章で「こんにちわ」を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 各音素が何フレーム分かを決めて、その数だけ引き伸ばす
↓ Flow 波形の「種」になるデータへ変換する
↓ Decoder 種を波形へ広げる
音声波形
最後のDecoderが、自作で共振器3本がやっていた仕事に相当します。
もう1点。同じ文章に対応する音声は1つに決まりません。ゆっくり言った「こんにちは」も、明るく言った「こんにちは」も、学習データには全部入っています。決まった対応を学ぼうとすると全部の平均になり、どれでもないぼやけた音が出てしまう。そこでVITSは音声を分布として扱い、生成時にそこから1つ選びます。同じ文を2回生成しても、まったく同じ波形にはなりません。
ここまでの整理
piper-plusの中を、前半と後半に分けて見てきました。
前半の音素化を担当していたのは、OpenJTalkでした。文字を音素の並びに変えるところで、自作でいえば手書きの読み辞書にあたります。
後半を担当していたのは、学習済みモデルです。各音素の長さを決め、抑揚を付け、波形を出す。この3つはどれも、実際の人の発話データから学んだ値を使っていました。
自作したほうは、その値を全部コードの表に書いていました。大きな違いは、その値を誰が決めているかだけです。
まとめ
追いかけた3つの問いに、1行ずつ答えます。
① 音を並べただけでは、なぜ声にならないのか
渡りが無いからです。渡りは音と音のあいだにしかなく、1音ずつ作った時点で生まれていません。
② 何を直せば声に近づくのか
1文字ずつ作るのをやめ、文全体を一度に渡すことです。新しいコードは1行も要りません。
③ それでも残る差は何か。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が採用している波形生成の方式