Android
SpeechSynthesis

Android: フォルマント合成で母音を合成してみる

More than 1 year has passed since last update.

Life is Tech ! メンター Advent Calendar 2017 16日目です.

Life is Tech! の Android コースでメンターをさせて貰っているあっすーです.

昨日はタナカ謙汰さんの Unity ちゃん記事でしたね. アツい.

Unity ちゃん可愛い.

閑話休題.

最近, Google HomeAmazon Echo といった「話すデバイス」が続々とリリースされていますね.

機械が人間の声を出して話す, そして, 機会が人間の言うことを聴くといった機能はこれからどんどん普及していくんじゃ無いかなぁ...と個人的には思っています.

今回は, 「機械が声を出すこと」にフォーカスを当てて, 音声を合成する手法として古くから知られているフォルマント音声合成を Android で実装していきたいと思います.

アプリのソースコードの紹介と合わせて, 「フォルマント合成ってなんぞや」という質問にも答えていきたいと思います.

読者は中学生〜のイメージで執筆しています.


1. 今回やること

画面を押したら, 母音 (あ, い, う, え, お) っぽい音がなるアプリを作る.

フォルマント合成 (とアプリ中のパラメータ) の元ネタはニコニコ動画の 「サイン波で声を作ってみた」 になります.

アプリを作る前に, こちらの動画を先に視聴してもいいかもしれません.

Android アプリのコードは, こちらの記事を参考にさせてもらっています.


2. 準備


2.1. ご注意

この記事は「AndroidでAudioTrackで矩形波を鳴らす」の記事で紹介されているアプリがある, というところをスタートにしています.

この記事で紹介されていることを手元で確かめるためには, リンク先の「AndroidでAudioTrackで矩形波を鳴らす」アプリを, 先に作る必要があります.


2.2. 元となるアプリの確認

元となるアプリは「画面をタッチしている間, 440hzの矩形波を鳴らす」という仕様になっています.

矩形波の音を鳴らすと, ファミコンの音のようなピコピコ音がなると思います.

今回は, このアプリを少し書き換えていきます.

具体的には, Oscillator クラスに手を加えていきます.

MainActivity の方はそのままにしています.


3. 波の重ね合わせ

今回行うフォルマント音声合成は, 簡単に言えば波の重ね合わせをしているだけです.

なので, まずは「AndroidでAudioTrackで矩形波を鳴らす」の記事中の Oscillator クラスで波の重ね合わせをやってみます.

元コードでは 440 Hz (= ラの音) を鳴らしていましたが, せっかくなので C major (C + E + G)を sin 波の重ね合わせで作ってみます.

音階と周波数の対応は MIDIのノートナンバーと周波数との対応表 を参考にしました.

元コード


Oscillator.java

   private double frequency = 440;

public double[] nextBuffer() {
for (int i = 0; i < buffer.length; i++) {
// まずサイン波を生成して値が正なら1、負なら-1とすることで矩形波を生成する。
// y = A * sin(2πft): 高校物理の波の式。
double sin = Math.sin(2 * Math.PI * t * frequency);
// 矩形波に変換している。
buffer[i] = sin > 0 ? 1 : -1;

t += 1 / sampleRate;
}
return buffer;
}


C major の和音を鳴らすコード


Oscillator.java

   private double frequencyC = 261.6;

private double frequencyE = 329.6;
private double frequencyG = 392.0;

public double[] nextBuffer() {
for (int i = 0; i < buffer.length; i++) {
// 順番に時刻 t に対応する, C/E/G (ド/ミ/ソ) の波を作る
double sinC = Math.sin(2 * Math.PI * t * frequencyC);
double sinE = Math.sin(2 * Math.PI * t * frequencyE);
double sinG = Math.sin(2 * Math.PI * t * frequencyG);

double sinCMajor = sinC + sinE + sinG; // 波を重ね合わせる
buffer[i] = sinCMajor; // バッファーに代入する

t += 1 / sampleRate; // 時刻 t を進める
}
return buffer;
}


この状態でアプリを起動して, タップすると和音がなっていることが確認できるかと思います.


3.1. sampleRate について

コードの中にしれっと「時刻 t を進める」とか書きましたが, 1 / sampleRate について簡単に解説します.

「いいから声を合成したいぜ!」

という方は, 3.1. を読み飛ばしてしまっても大丈夫です.

sampleRate は「1秒にいくつ値が必要になるか」を表していて, 1 / sampleRateとすることで, 次の時刻を表現することができます.

こちらのサイトで紹介されている図で言えば, 図中の真ん中のグラフにある点一つ一つが, buffer[i] に代入される値であり, t += 1 / sampleRate とすることで, 次の点を計算できるようになる...といったところでしょうか.

6379697.jpg

もっと知りたい方は, サンプリング周波数, 量子化, アナログ・デジタルあたりの言葉でググったらいい記事がたくさん出てくると思います.


4. フォルマント合成をしてみる

前項では, C/E/G (ド/ミ/ソ) の周波数の波3つを重ね合わせて C major の和音を合成しました.


  • C: 261.6 Hz

  • E: 329.6 Hz

  • G: 392.0 Hz

同じように, 複数の波を重ね合わせる方法で, 「あ/い/う/え/お」 っぽい音を作ることができます.1

この「ある周波数の波の組み合わせをある比率で重ね合わせることで, 音声を合成する」方法が, フォルマント音声合成と呼ばれる手法の一つです.

「そんなに簡単にできるの?」

と思うかもしれませんが,母音に関してはその母音に対応する周波数成分が含まれていることが知られていて, フォルマント合成ではその性質を利用します.

「あーーーー」と声を出している人の声道を, その形の管楽器みたいなもので置き換えてみると理由をイメージしやすいでしょうか.


4.1. 実装する


4.1.1. あ

まずは, 「あ」っぽい音を鳴らしてみます.


Oscillator.java


public double[] nextBuffer() {

for (int i = 0; i < buffer.length; i++) {
// double sinC = Math.sin(2 * Math.PI * t * frequencyC);
// double sinE = Math.sin(2 * Math.PI * t * frequencyE);
// double sinG = Math.sin(2 * Math.PI * t * frequencyG);

// double sinCMajor = sinC + sinE + sinG;
// buffer[i] = sinCMajor;

double[] f = new double[6]; // 生成する波を一旦入れておく変数
Arrays.fill(f, 0); // 一応ゼロで初期化する...

// あ?
f[0] = 0.19 * Math.sin(2 * Math.PI * t * 1040);
f[1] = 0.09 * Math.sin(2 * Math.PI * t * 520);
f[2] = 0.08 * Math.sin(2 * Math.PI * t * 780);
f[3] = 0.08 * Math.sin(2 * Math.PI * t * 1300);
f[4] = 0.07 * Math.sin(2 * Math.PI * t * 260);
f[5] = 0.07 * Math.sin(2 * Math.PI * t * 1560);
// 振幅 * Math.sin(2 * Math.PI * 時刻 * 周波数)

buffer[i] = f[0] + f[1] + f[2] + f[3] + f[4] + f[5]; // 波を重ね合わせる

t += 1 / sampleRate;
}

return buffer;
}


実行すると, 画面を押せば「あ」がなるアプリになったと思います.

...どうです?

それは「あ」なんです!

若干苦しいものを感じているかもしれませんが...

他の母音と聴き比べればなんかそう聞こえる気がしてくる...はず...

この調子でどんどん行きます.


4.1.2. い/う/え/お の前に

...っと, その前に.

母音を鳴らし分ける必要があるので, その準備をします.

Oscillator.java 内で, enum を宣言します.

next() メソッドを Vowel の宣言の中でごちゃごちゃ書いてあるのは, あとで状態遷移を簡単に実装するためです.


Oscillator.java

public class Oscillator {

// なんか色々書いてある

// Oscillator クラスの一番最後に, Vowel (母音) の enum を宣言します
enum Vowel {
A {
public Vowel next() {
return I;
}
}, I {
public Vowel next() {
return U;
}
}, U {
public Vowel next() {
return E;
}
}, E {
public Vowel next() {
return O;
}
}, O {
public Vowel next() {
return A;
}
};

abstract Vowel next();
}
}


Oscillator.java 内で, Vowel 型の変数を宣言します.


Oscillator.java

public class Oscillator {

private double frequency = 440;

private double[] buffer;
private double t = 0;
private double sampleRate;

// targetVowel という変数を宣言
// tagetVowel の値毎に処理を分ける
private Vowel targetVowel = Vowel.A;

// なんか色々書いてある

}



4.1.3. あ/い/う/え/お

if 文で targetVowel に入っている値ごとに, 配列 f に代入する値を変えていきます.

それぞれの母音に対応する振幅, 周波数は「サイン波で声を作ってみた」 で紹介されている値をそのまま持ってきています.


Oscillator.java

    public double[] nextBuffer() {

for (int i = 0; i < buffer.length; i++) {

double[] f = new double[6];
Arrays.fill(f, 0);

// targetVowel の値毎に処理を分ける
// enum を使うことで, なんの値が入ってるかわかりやすい! ...気がしませんか?
if (targetVowel == Vowel.A) {
// あ?
f[0] = 0.19 * Math.sin(2 * Math.PI * t * 1040);
f[1] = 0.09 * Math.sin(2 * Math.PI * t * 520);
f[2] = 0.08 * Math.sin(2 * Math.PI * t * 780);
f[3] = 0.08 * Math.sin(2 * Math.PI * t * 1300);
f[4] = 0.07 * Math.sin(2 * Math.PI * t * 260);
f[5] = 0.07 * Math.sin(2 * Math.PI * t * 1560);
} else if (targetVowel == Vowel.I) {
// い?
f[0] = 0.52 * Math.sin(2 * Math.PI * t * 260);
f[1] = 0.03 * Math.sin(2 * Math.PI * t * 520);
f[2] = 0.02 * Math.sin(2 * Math.PI * t * 2860);
f[3] = 0.02 * Math.sin(2 * Math.PI * t * 3380);
f[4] = 0.01 * Math.sin(2 * Math.PI * t * 3120);
} else if (targetVowel == Vowel.U) {
//う?
f[0] = 0.32 * Math.sin(2 * Math.PI * t * 260);
f[1] = 0.13 * Math.sin(2 * Math.PI * t * 1560);
f[2] = 0.11 * Math.sin(2 * Math.PI * t * 520);
f[3] = 0.02 * Math.sin(2 * Math.PI * t * 1300);
f[4] = 0.02 * Math.sin(2 * Math.PI * t * 1040);
} else if (targetVowel == Vowel.E) {
//え?
f[0] = 0.18 * Math.sin(2 * Math.PI * t * 260);
f[1] = 0.14 * Math.sin(2 * Math.PI * t * 520);
f[2] = 0.13 * Math.sin(2 * Math.PI * t * 780);
f[3] = 0.03 * Math.sin(2 * Math.PI * t * 2860);
f[4] = 0.03 * Math.sin(2 * Math.PI * t * 1040);
} else if (targetVowel == Vowel.O) {
//お?
f[0] = 0.24 * Math.sin(2 * Math.PI * t * 1040);
f[1] = 0.14 * Math.sin(2 * Math.PI * t * 520);
f[2] = 0.11 * Math.sin(2 * Math.PI * t * 260);
f[3] = 0.10 * Math.sin(2 * Math.PI * t * 780);
}

buffer[i] = f[0] + f[1] + f[2] + f[3] + f[4] + f[5]; // 波を重ね合わせる。

t += 1 / sampleRate; // time を 1 進める。
}

return buffer;
}


指が離れたら, 次の母音を鳴らすように状態を変えるようにします.


Oscillator.java

    public void reset() {

t = 0;
targetVowel = targetVowel.next(); // 次の状態に遷移する
// enum の中で抽象 (abstract) メソッドを使って小細工を弄したおかげで簡単に実装できる!
// そんな気がする
}

これで, タップする毎に母音が変わるようになったと思います.

聴き比べれば...

なんとなく...

「あいうえお」っぽく無いですか...?

個人的には, 「え」が苦しいけれど,

それ以外は「っぽい」と思います.


4.2. 改良する

動画 の最後に紹介されているように, 声の高さを変えられるようにします.


4.2.1. 実は...

突然ですが, さっき書いたコード中の周波数を表にまとめてみます.

「あ」

周波数
振幅

第1フォルマント
1040
0.19

第2フォルマント
520
0.09

第3フォルマント
780
0.08

第4フォルマント
1300
0.08

第5フォルマント
260
0.07

第6フォルマント
1560
0.07

「い」

周波数
振幅

第1フォルマント
260
0.19

第2フォルマント
520
0.09

第3フォルマント
2860
0.08

第4フォルマント
3380
0.08

第5フォルマント
3120
0.07

「う」

周波数
振幅

第1フォルマント
260
0.19

第2フォルマント
1560
0.09

第3フォルマント
520
0.08

第4フォルマント
1300
0.08

第5フォルマント
1040
0.08

「え」

周波数
振幅

第1フォルマント
260
0.19

第2フォルマント
520
0.09

第3フォルマント
780
0.08

第4フォルマント
2860
0.08

第5フォルマント
1040
0.07

「お」

周波数
振幅

第1フォルマント
1040
0.19

第2フォルマント
520
0.09

第3フォルマント
260
0.08

第4フォルマント
780
0.08

お気づきでしょうか? (何に)

実は...


  • 振幅が大きい順に並んでいたのです!


    • 大きい方から順番に第1フォルマント, 第2フォルマント...と言います.

    • どうせあとで足すので今回は順番にさして意味はありません



  • 周波数が 260 の倍数になっていたのです!


    • 「な、なんだってーーー!!!」

    • こちらが, 合成される声の高さを変える上で重要になります.

    • 今回は 260 Hz が 基本周波数 になっています.



基本周波数は, ざっくり言えば「重ね合わせられている音の中で一番低い (周波数の)

音」 です.


4.2.1. 基本周波数を使って書き直す

double 型の変数 f0 を宣言する.

public class Oscillator {

private double frequency = 440;
// 基本周波数 f0 を宣言する
private double f0 = 280; // fundamental frequency

private double[] buffer;
private double t = 0;
private double sampleRate;

// なんか色々書いてある
}

母音のフォルマント周波数それぞれに, f0 を適用していきます.

// 例えばこんな感じに書き換えていきます. 

// f[2] = 0.08 * Math.sin(2 * Math.PI * t * 780);
f[2] = 0.08 * Math.sin(2 * Math.PI * t * f0 * 3);

数が多いので大変ですが, 全てに適用します.

public class Oscillator {

// なんか色々書いてある

public double[] nextBuffer() {

for (int i = 0; i < buffer.length; i++) {
double[] f = new double[6];
Arrays.fill(f, 0);

// f0 (=280) の倍数で書き換える
if (targetVowel == Vowel.A) {
// あ?
f[0] = 0.19 * Math.sin(2 * Math.PI * t * f0 * 4);
f[1] = 0.09 * Math.sin(2 * Math.PI * t * f0 * 2);
f[2] = 0.08 * Math.sin(2 * Math.PI * t * f0 * 3);
f[3] = 0.08 * Math.sin(2 * Math.PI * t * f0 * 5);
f[4] = 0.07 * Math.sin(2 * Math.PI * t * f0);
f[5] = 0.07 * Math.sin(2 * Math.PI * t * f0 * 6);
} else if (targetVowel == Vowel.I) {
// い?
f[0] = 0.52 * Math.sin(2 * Math.PI * t * f0);
f[1] = 0.03 * Math.sin(2 * Math.PI * t * f0 * 2);
f[2] = 0.02 * Math.sin(2 * Math.PI * t * f0 * 11);
f[3] = 0.02 * Math.sin(2 * Math.PI * t * f0 * 13);
f[4] = 0.01 * Math.sin(2 * Math.PI * t * f0 * 12);
} else if (targetVowel == Vowel.U) {
//う?
f[0] = 0.32 * Math.sin(2 * Math.PI * t * f0);
f[1] = 0.13 * Math.sin(2 * Math.PI * t * f0 * 6);
f[2] = 0.11 * Math.sin(2 * Math.PI * t * f0 * 2);
f[3] = 0.02 * Math.sin(2 * Math.PI * t * f0 * 5);
f[4] = 0.02 * Math.sin(2 * Math.PI * t * f0 * 4);
} else if (targetVowel == Vowel.E) {
//え?
f[0] = 0.18 * Math.sin(2 * Math.PI * t * f0);
f[1] = 0.14 * Math.sin(2 * Math.PI * t * f0 * 2);
f[2] = 0.13 * Math.sin(2 * Math.PI * t * f0 * 3);
f[3] = 0.03 * Math.sin(2 * Math.PI * t * f0 * 11);
f[4] = 0.03 * Math.sin(2 * Math.PI * t * f0 * 4);
} else if (targetVowel == Vowel.O) {
//お?
f[0] = 0.24 * Math.sin(2 * Math.PI * t * f0 * 4);
f[1] = 0.14 * Math.sin(2 * Math.PI * t * f0 * 2);
f[2] = 0.11 * Math.sin(2 * Math.PI * t * f0);
f[3] = 0.10 * Math.sin(2 * Math.PI * t * f0 * 3);
}

buffer[i] = f[0] + f[1] + f[2] + f[3] + f[4] + f[5]; // 波を重ね合わせる。

t += 1 / sampleRate; // time を 1 進める。
}

return buffer;
}

}

ただ, 数字を計算して出すように書き換えただけなので,

このままでは書き換えた意味はそれほどありません.


4.2.2. なんのために書き換えたか

f0280 を設定していたところを, 220 や, 260 など, 色々値を書き換えてみてください.

    // 基本周波数 f0 を宣言する

// private double f0 = 280; // fundamental frequency
private double f0 = 220; // 合成される声が低くなるはず

母音の音の高さが変わって聞こえると思います.

人それぞれ, 声の高さは違いますよね.

男女の違いや年齢による違いが, 人の声にはあります.

それらの「声の高さの違い」が, 基本周波数 ( f0 ) に現れます.

その特性を, 今回のアプリに利用しています.


5. まとめ

今回は, 音声を合成する手法の一つである「フォルマント音声合成」を Android のアプリで実装してみました.

合成される音の品質は, 「っぽい」の域をこえないものだったかもしれませんが,


  • Android で音を鳴らしてみる

  • 波形を重ね合わせてみる

  • フォルマント周波数を使って母音を合成してみる

といった作業を通じて, 「Android の音」や「音声合成」に,

触れてみました.

少しでも人の声について興味を持ってもらえたら嬉しいです.

明日は, コミュニケーションマスターになれる記事だそうです.

楽しみですね! それではまた!!

この記事について何か間違いを発見した場合やや, 不明な点があった場合はコメントをしていただきますようにお願いします.





  1. かなりシンプルな方法で実装するので, 「そのつもりで聞けばそう聞こえなくも無い」くらいの音です. 過度な期待は厳禁...