(2)C言語で音声合成もどき ~母音の生成~


この記事は素人が音声合成で遊んでいるだけの記事です。完全に行き当たりばったりなので、紹介している内容の保証はできません。また、記事の内容を予告なく変更することがあります。


生成した「お」の波形
今回は本題の音声合成に移っていきます。
前回は波形データをWAVファイルに書き出す部分を作ったので、ここからはさっそく母音の合成をしていきます。



ざっくりいうと人間の声は、声帯で基となる音がつくられ、声道で加工されたものです。
今回のプログラムは、この「声帯→声道」という構成を参考にして音声を合成していきます。

まずは、声帯にあたる部分から実装することにします。

声帯が振動すると、周期的な波が発生します。
つまり、なんらかの周期的な波を使えば模倣できそうです。
一般的なシンセサイザーのようにノコギリ波や三角波を使うこともできますが、ここでは声帯振動の波を模倣したRosenberg波とよばれる波形を使います。

以下がRosenberg波の式です。
出典:https://dspace.jaist.ac.jp/.../896paper.pdfのP14 (2.1)、一部式の変形・表記変更あり
τ1は声門開大期*1の長さ、τ2は声門閉小期*2の長さを表します。
*1 声門開大期……声門が閉じた状態から開ききるまでの時間
*2 声門閉小期……声門が開いた状態から閉じきるまでの時間
これらの値は、τ1 + τ2 が 0〜1 の範囲におさまるように決めます。
今回は τ1 = 0.90, τ2 = 0.05 としました。

Cで書くとこんな感じです。

double GenRosenberg(double freq)
{
    /* Rosenberg波を生成 */
    static double t = 0;
    double tau = 0.90;    /* 声門開大期 */
    double tau2 = 0.05;   /* 声門閉小期 */
    double sample = 0.0;

    t += freq / (double)サンプリング周波数;
    t -= floor(t);

    if (t <= tau) {
        sample = 3.0*pow(t/tau,2.0)-2.0*pow(t/tau,3.0);
    } else if (t < tau+tau2) {
        sample = 1.0-pow((t-tau)/tau2,2.0);
    }
    return sample;
}

これで声のベースとなる声帯振動ができました。
この時点ではただの「ブー」という音でしかないので、さらに声道を実装して母音らしく加工していきます。



声帯から出た音は、口や鼻などの声道で共鳴し、倍音が強調されたり弱まったりします。
声道の形状を変化させると、強調される倍音が変わります。
この共鳴ぐあいの違いによって、母音「あいうえお」がつくられるわけです。
声道
(かなり簡略化しています)
そこで、先ほどのRosenberg波にフィルタをかけて、声道の共鳴を真似すれば、母音らしい音を作りだせることになります。

今回は、フィルタとしてBPF(バンドパスフィルタ)を使います。
BPFについてざっくり説明すると、ある周波数付近の波だけを通すフィルタです。
BPFには、2種類の設定項目があります。
  • カットオフ周波数 (fc)
  • Q値
カットオフ周波数 fc で、何 Hz 付近を通すかを決めます。
また、通す周波数の強調具合を決める値がQ値です。Q値を大きくするほど、より強調されます。

BPFを使って 100 Hz 付近以外の音を小さくすれば、逆に 100Hz が強調されます。
こうすることで、あたかも100Hzで共鳴しているかのように聞こえます。

具体的なBPFの実装方法は、通称「RBJ Cookbook」とよばれる、IIR型デジタルフィルタについて解説した文書が参考になります。
http://www.musicdsp.org/en/latest/Filters/197-rbj-audio-eq-cookbook.html
https://www.w3.org/2011/audio/audio-eq-cookbook.html (HTML版)
また、g200kgさんによる日本語訳もあります。
https://www.g200kg.com/jp/docs/makingeffects/78743dea3f70c8c2f081b7d5187402ec75e6a6b8.html


Cで実装するとこんな感じです。
void IIR_DesignBPF(double f, double Q, double param[5])
{
    /* フィルタ係数を求める */
    double omega = 2.0 * M_PI * f / (double)SMPL;
    double alpha = sin(omega) / (2.0 * Q);
    double a0    = 1.0 + alpha;

    param[0] = alpha             / a0;      /* b0/a0 */
    param[1] = 0.0               / a0;      /* b1/a0 */
    param[2] = -alpha            / a0;      /* b2/a0 */
    param[3] = -2.0 * cos(omega) / a0;      /* a1/a0 */
    param[4] = (1.0 - alpha)     / a0;      /* a2/a0 */
}
double IIR_ApplyFilter(double base, double param[5], double delay[4])
{
    /* フィルタを適用 */
    double sample = 0.0;

    sample += param[0] * base;
    sample += param[1] * delay[0];
    sample += param[2] * delay[1];
    sample -= param[3] * delay[2];
    sample -= param[4] * delay[3];

    delay[1] = delay[0]; delay[0] = base;
    delay[3] = delay[2]; delay[2] = sample;

    return sample;
}
/* 使用例 */
double HogeHoge(double signal)
{
    double output;             /* 出力信号 */
    double param[5];           /* フィルタ係数 */
    double delay[4] = {0.0};   /* IIR用のディレイタップ(初期値は0にしておく) */

    /* BPF, カットオフ周波数=1000Hz, Q=20.0 のフィルタ係数を求める */
    IIR_DesignBPF(1000.0, 20.0, param);

    /* フィルタの適用 */
    output = IIR_ApplyFilter(signal, param, delay);
    return output;
}

IIR_DesignBPF() 
で fc と Q値 から BPF を設計し、
IIR_ApplyFilter() で入力信号にフィルタをかけます。
最終的なコードではこれらの関数を同時に呼び出すので、1つの関数にまとめてしまっても問題ありません。


さて、声道の共鳴周波数は1個だけではなく、たくさん存在します。
ところが、1個のBPFにつき、1つの共鳴周波数しか表すことができません。

そこで、必要な共鳴の数にあわせて、BPFを用意します。
コードで表すとこんな感じです。この例では、5個のBPFを組み合わせています。
double ApplyFormant(double input, double formant[5], double param[5][5], double delay[5][4])
{
    int i;
    double output;

    /* 別々に5種類のBPFを通した波形を、すべて足し合わせる */
    output = 0.0;
    for (i = 0; i < 5; i++) {
        IIR_DesignBPF(formant[i], 20.0, param[i]);
        output += IIR_ApplyFilter(input, param[i], delay[i]);
    }

    return output;
}



最後に、それぞれの母音に対応する共鳴周波数を計算します。

ふつうのフォルマント合成では、各母音の共鳴周波数表を用意します。
しかし、この方式で声質を変えようとすると、そのたびに周波数表を書き換えなければいけません。
どうせなら声質にあわせて、周波数表を自動計算したいものです。
というわけで、声道の長さをもとにBPFのカットオフ周波数を計算するようにしてみます。

感覚的には、声道の長さが変わると声の太さが変わります。
平均的な声道長は、成人男性で16.9cm、成人女性で14.1cm程度だそうです (Wikipedia)。
今回はあいだをとって15.0cmとしました。
(お兄さん〜ボーイッシュなお姉さんくらいの中性的な声質になると思います)

声道長が決まったので、この管が完全に真っ直ぐなときの共鳴周波数を計算してみます。
人間の声道は声帯でフタをされているので、一方が開いてもう一方が閉じている管、閉管として考えます。
真っ直ぐな閉管の共鳴周波数は、高校物理の「気柱の共鳴の式」で計算できます。
1番目の共鳴周波数 $F_1$ (Hz) は、$c$ を音速(cm/s)、$l$ を声道の長さ(cm)とすると、
で計算できます。
さらに、まっすぐな閉管の共鳴周波数は、$F_1$ の奇数倍になる性質があります。
つまり $F_2$, $F_3$, … を求めるには、$F_1$ を 3倍, 5倍, … して求められます。
今回はBPFを5個使うので、$F_5$ まで計算します。

さて、ここまでで求めた共鳴周波数は「声道が完全にまっすぐなとき」のものです。
実際の声道は、口や舌の動き等でぐにゃぐにゃと変形するので、共鳴周波数が少しズレます。
今回はまっすぐな管の共鳴周波数に変動量を掛け算することで、フォルマント周波数にズレを加えてみます。

気合でパラメータを調整して変動量の表を作ってみました。F1とF2だけでも母音を表現できるという話があるように、F4以降を変更する必要はあまりなさそうです。

F1F2F3F4F5
1.600.701.101.001.00
0.701.401.201.001.00
0.800.900.901.001.00
1.201.301.101.001.00
1.150.501.201.001.00



ここまでの内容を実装していきます。
前回のWAVファイルの生成と、今回のRosenberg波・BPF・共鳴周波数計算を組み合わせます。

#define _USE_MATH_DEFINES
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>

/* サンプリング周波数 */
#define SMPL    44100

/* 母音の種類 */
typedef enum {
    VOWEL_A,
    VOWEL_I,
    VOWEL_U,
    VOWEL_E,
    VOWEL_O
} VowelType;

#define MAX(a,b)    (((a)<(b))?(b):(a))
#define MIN(a,b)    (((a)<(b))?(a):(b))

void WriteWavFile(const char *filename, const char *buf, int size)
{
    /* WAVファイルへ書き出し */
    int filesize = 44 + size;
    FILE *fp;
    char *work;

    fp = fopen(filename, "wb");
    if (fp == NULL) { return; }
    work = (char *) malloc(filesize);
    if (work == NULL) { return; }

    memcpy(work, "RIFF", 4);
    work[4] = (filesize - 8) >> 0  & 0xff;
    work[5] = (filesize - 8) >> 8  & 0xff;
    work[6] = (filesize - 8) >> 16 & 0xff;
    work[7] = (filesize - 8) >> 24 & 0xff;
    memcpy(work+8, "WAVEfmt ", 8);
    work[16] = 16; work[20] = work[22] = 1;
    work[17] = work[18] = work[19] = work[21] = work[23] = 0;
    work[24] = work[28] = SMPL >> 0  & 0xff;
    work[25] = work[29] = SMPL >> 8  & 0xff;
    work[26] = work[30] = SMPL >> 16 & 0xff;
    work[27] = work[31] = SMPL >> 24 & 0xff;
    work[32] = 1; work[33] = 0;
    work[34] = 8; work[35] = 0;
    memcpy(work+36, "data", 4);
    work[40] = size >> 0  & 0xff; work[41] = size >> 8  & 0xff;
    work[42] = size >> 16 & 0xff; work[43] = size >> 24 & 0xff;
    memcpy(work + 44, buf, size);

    fwrite(work, filesize, 1, fp);
    fclose(fp);
    free(work);
}

double GenRosenberg(double freq)
{
    /* Rosenberg波を生成 */
    static double t = 0;
    double tau  = 0.90;  /* 声門開大期 */
    double tau2 = 0.05;  /* 声門閉小期 */
    double sample = 0.0;

    t += freq / (double)SMPL;
    t -= floor(t);
    if (t <= tau) {
        sample = 3.0*pow(t/tau,2.0)-2.0*pow(t/tau,3.0);
    } else if (t < tau+tau2) {
        sample = 1.0-pow((t-tau)/tau2,2.0);
    }
    return sample;
}

void IIR_DesignBPF(double f, double Q, double param[5])
{
    /* BPFのフィルタ係数を求める */
    double omega = 2.0 * M_PI * f / (double)SMPL;
    double alpha = sin(omega) / (2.0 * Q);
    double a0 = 1.0 + alpha;
    param[0] = alpha             / a0;
    param[1] = 0.0               / a0;
    param[2] = -alpha            / a0;
    param[3] = -2.0 * cos(omega) / a0;
    param[4] = (1.0 - alpha)     / a0;
}

double IIR_ApplyFilter(double base, const double param[5], double delay[4])
{
    /* IIRフィルタを適用 */
    double sample = 0.0;
    sample += param[0] * base;
    sample += param[1] * delay[0];
    sample += param[2] * delay[1];
    sample -= param[3] * delay[2];
    sample -= param[4] * delay[3];
    delay[1] = delay[0]; delay[0] = base;
    delay[3] = delay[2]; delay[2] = sample;
    return sample;
}

double ApplyFormant(double input, const double formant[5], double param[5][5], double delay[5][4])
{
    /* BPFでフォルマントを付加 */
    int i;
    double output = 0.0;
    for (i = 0; i < 5; i++) {
        IIR_DesignBPF(formant[i], 20.0, param[i]);
        output += IIR_ApplyFilter(input, param[i], delay[i]);
    }
    return output;
}

int main(void)
{
    int i, size;
    char *buf;
    double freq, vtlen, in, out;
    double formant[5], param[5][5], delay[5][4] = {0};
    double male[5][5] = {
        /* フォルマント周波数の変動値 */
        /*  F1,   F2,   F3,   F4,   F5 */
        { 1.60, 0.70, 1.10, 1.00, 1.00 },
        { 0.70, 1.40, 1.20, 1.00, 1.00 },
        { 0.80, 0.90, 0.90, 1.00, 1.00 },
        { 1.20, 1.30, 1.10, 1.00, 1.00 },
        { 1.15, 0.50, 1.20, 1.00, 1.00 }
    };

    /* 設定 */
    size  = SMPL * 1; /* 1秒間 */
    buf   = (char *) malloc(size);
    freq  = 220.0;    /* 基本周波数 */
    vtlen = 15.0;     /* 声道の長さ */

    /* フォルマント周波数を計算 */
    for (i = 0; i < 5; i++) {
        formant[i] = (34000.0*(2*i+1))/(4.0*vtlen);
        formant[i] *= male[VOWEL_A][i]; /* 「あ」 */
    }

    /* 波形を生成 */
    for (i = 0; i < size; i++) {
        in = GenRosenberg(freq);
        out = ApplyFormant(in, formant, param, delay);
        buf[i] = (char)(128.0 * MIN(MAX(out, -1.0), 1.0)) + 128;
    }

    /* 書き出し */
    WriteWavFile("test.wav", buf, size);
    free(buf);
    return 0;
}

コンパイルして実行すると、母音「あ」を合成した test.wav が生成されます。
コード中の以下の部分を VOWEL_I, VOWEL_U, VOWEL_E, VOWEL_O に変えると「い」「う」「え」「お」を生成できます。

  formant[i] *= male[VOWEL_A][i];

あ、い、う、え、お をそれぞれ生成させてつなげた音声がこちら。
(お兄さんVer.0.5)

他にも、声道の長さ (vtlen) やRosenberg波のパラメータ (tau1, tau2) 等を変えてみると、声の雰囲気が変わります。
いろいろな声を作ってみると面白いかもしれません。



コメント

  1. 声を作りたいと思っていたらこちらにたどり着きました!
    初心者ですがプログラムも公開されていて試すことができるので嬉しいです(*´ω`*)
    勉強していろいろな声を作れるようになりたいです。

    返信削除
    返信
    1. 音声合成のわくわく感を味わっていただければ幸いです。
      現在「C言語で音声合成もどき」シリーズの続編を書いています。
      近いうちに投稿するつもりですので、ぜひご期待ください!

      削除

コメントを投稿

このブログの人気の投稿

基本波形の生成

(1)C言語で音声合成もどき ~WAVファイルを生成する~