はじめに

概要

今回は簡易的なText-To-Speech機能(フォルマント音声合成)を実装してスタックチャンに喋ってもらおうと思います。 音声合成のライブラリは性能が高いものがいろいろあるとは思うのですが、 勉強も兼ねてシンプルな音声合成機能を実装してみます(実装の大枠はGeminiにお願いします)。

過去の記事

M5Stack製のスタックチャンをmicro-ROSとArduinoで動かす方法を記事にまとめました。

スタックチャンをmicro-ROSで動かす - Arduino編

移動ロボットとスタックチャンの連携もやってみました。

スタックチャンをラズパイマウスに搭載してカメラを使った顔の検出と追従

Geminiに音声合成機能を実装してもらった

「勉強も兼ねて実装する」と言いつつ、音声合成機能のプログラムはGeminiに書いてもらいました。 この記事ではGeminiに実装してもらったプログラムを読み解いていこうと思います。

Geminiに実装してもらったプログラムは下記リンクで公開しています。

GitHub - stack-chan_micro-ros_arduino/speech.ino

ちなみにプログラム全体の流れとしては下記の通りです。

  1. トピックで文字列を受け取る
  2. 文字列を音声に変換する
  3. スピーカーで音声を出力する

この記事では基本的なArduinoのお作法やmicro-ROSの処理の説明は省いて、 音声合成機能部分を読み解いていきます。

プログラムを読み解く

注意: 音声合成について素人なので説明が間違っていたらすみません。

フォルマント

このプログラムではフォルマント音声合成でテキストを音声に変換して、 人工的に人間の声を模倣しています。 フォルマントについてはWikipediaを参照します。

Wikipedia - フォルマント

フォルマントは人間の音声内で周囲よりも強度が高い周波数帯域のことです。 フォルマントは音声内に複数存在することもあり、周波数の低いほうから「第一フォルマント(F1)」、「第二フォルマント(F2)」、…と続いていきます。 私の理解だとフォルマントがその音声の特徴になるかと思います。 つまり逆に考えると、フォルマントを人工的に再現できるとその音声が生み出せるということになります。

ちなみに人間の音声におけるフォルマントは、主に口腔や鼻腔等での共鳴によって発生するようです。 特に、第一フォルマントは口の開きの大きさ、第二フォルマントは舌の前後の位置に影響されるとのことです。 そして今回のプログラムでは、この第一フォルマントと第二フォルマントの周波数を再現することで母音(a,i,u,e,o)の音を模倣しています。

周波数は下記のように設定されています。 1列目が第一フォルマント、2列目が第二フォルマントです。 母音のa,i,u,e,oの5つそれぞれに値を設定します。

const float VOWEL_FREQ[5][2] = {
    {800.0, 1200.0},  // a
    {300.0, 2300.0},  // i
    {300.0, 1200.0},  // u
    {500.0, 1900.0},  // e
    {500.0, 800.0}    // o
};

これらの周波数は下記リンクの研究で計測されています。

日本語母音のフォルマント周波数の年齢による変化

Biquad Filter

このプログラムでは、Biquad Filterというフィルタを使って、 音のベースとなる非対称の矩形波を入力し、指定した周波数(第一フォルマント、第二フォルマント)部分を抜き出した滑らかな波を出力します。 そして第一フォルマントと第二フォルマントのそれぞれの波を足し合わせて母音(a,i,u,e,o)の音声を作成します。

Biquad Filterの数式は下記サイトで確認できます。

Cookbook formulae for audio equalizer biquad filter coefficients

プログラムの抜粋は下記です。

class BiquadFilter {
 private:
  float b0, b1, b2, a1, a2;
  float x1, x2, y1, y2;

 public:
  BiquadFilter() : x1(0), x2(0), y1(0), y2(0) {}
  void setBandpass(float freq, float q) {
    float w0 = 2.0 * PI * freq / SAMPLE_RATE;
    float alpha = sin(w0) / (2.0 * q);
    float a0 = 1.0 + alpha;
    b0 = alpha / a0;
    b1 = 0.0;
    b2 = -alpha / a0;
    a1 = -2.0 * cos(w0) / a0;
    a2 = (1.0 - alpha) / a0;
  }
  float process(float x) {
    float y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
    x2 = x1;
    x1 = x;
    y2 = y1;
    y1 = y;
    return y;
  }
};

setBandpass()で抜き出したい周波数を設定すると数式内のa1,a2,b0,b1,b2の係数が計算されます。 process()でベースとなる非対称の矩形波を入力xとして渡すと、x1,x2,y1,y2のように過去の2つ分の入力と出力を計算に使って、 滑らかな波yが出力されます。

(数式自体はよく分かっていないです。)

音声を作成する手順

実際に音声を作成する手順は下記のspeechTask()で行われています。

void speechTask(void *pvParameters) {
  ...
}

上から順番に見ていくと、 まずは受け取った文字列を一文字ずつ処理しています。 下記抜粋の部分ではその文字が母音(a,i,u,e,o)か子音かを判定しています。

for (int idx = 0; idx < len; idx++) {
  char c = tolower(current_speech_text[idx]);
  int vowel_idx = -1;
  if (c == 'a')
    vowel_idx = 0;
  else if (c == 'i')
    vowel_idx = 1;
  else if (c == 'u')
    vowel_idx = 2;
  else if (c == 'e')
    vowel_idx = 3;
  else if (c == 'o')
    vowel_idx = 4;
  }
  ...
}

その文字が母音だった場合は、その母音に対応した音を作成します。 まずは第一フォルマントと第二フォルマントぞれぞれでフィルタを作成します。

if (vowel_idx != -1) {
  f1.setBandpass(VOWEL_FREQ[vowel_idx][0], 8.0);
  f2.setBandpass(VOWEL_FREQ[vowel_idx][1], 10.0);

次に母音の長さVOWEL_FRAMES分だけ音を作成していきます。

あらかじめ音の高さ(pitch)とサンプルレートを設定しており、 そこから1サンプルごとの波が進む割合phaseIncrementを計算します。 そもそもphaseは1つの波の作成割合で0.0~1.0を繰り返します。 そしてphaseが0.0~0.3の時は1.0、0.3~1.0の時は-1.0の非対称の矩形波sourceを作ります。

これを第一フォルマントと第二フォルマントのフィルタにそれぞれ入力して、滑らかな波の出力を取得します。

for (int frame = 0; frame < VOWEL_FRAMES; frame++) {
  float phaseIncrement = pitch / SAMPLE_RATE;
  for (int i = 0; i < BUFFER_SIZE; i++) {
    phase += phaseIncrement;
    if (phase >= 1.0) phase -= 1.0;
    float source = (phase < 0.3) ? 1.0 : -1.0;
    float out1 = f1.process(source);
    float out2 = f2.process(source);

これで第一フォルマントと第二フォルマントの音は取得できたわけですが、 急に音量が変化するとスピーカーがプツプツしてしまうので、 音量をフェードインとフェードアウトさせて変化を緩やかにさせます。

float amp = 1.0;
// fade-in
if (frame == 0) {
  if (i < BUFFER_SIZE / 2) {
    amp = (float)i / (BUFFER_SIZE / 2);
  }
}
// fade-out
if (frame == VOWEL_FRAMES - 1) {
  if (i > BUFFER_SIZE / 2) {
    amp = 1.0 - (float)(i - BUFFER_SIZE / 2) / (BUFFER_SIZE / 2);
  }
}

母音作成の最後の手順として、フィルタから出力した第一フォルマントと第二フォルマントの波を足し合わせます。 また、volume_gainをかけて音全体の音量を調整します。 そしてplayRaw()でM5Stackのスピーカーで音を鳴らします。

      float mixed = (out1 + out2 * 0.6) * amp;
      audio_buffer[i] = (int16_t)(mixed * volume_gain);
    }
    M5.Speaker.playRaw(audio_buffer, BUFFER_SIZE, SAMPLE_RATE, false, 1, 0);
  }
}

子音の作成では、実際には子音の音を作成しているわけではなく、 あらかじめ設定したフレーム分だけ無音を作り出して人間の耳に子音っぽく聞こえるようにしています。

else {
  memset(audio_buffer, 0, sizeof(audio_buffer));
  for (int frame = 0; frame < CONSONANT_FRAMES; frame++) {
    M5.Speaker.playRaw(audio_buffer, BUFFER_SIZE, SAMPLE_RATE, false, 1, 0);
  }
}

以上の流れでテキストを音声に変換できるようになりました。

スタックチャンに喋ってもらった

実際にスタックチャンに喋ってもらった動画を下記YouTubeに投稿しました。 「ohayou gozaimasu(おはようございます)」という文字列を送って音声に変換しています。

おわりに

今回はスタックチャンに喋ってもらうために簡易的なText-To-Speech機能を実装してみました。 音声合成については全く知識がなかったのですがGeminiの助けも借りつつ少し理解できてよかったです。 これでコミュニケーションロボットとしての第一歩を踏み出せたかなと思います。 この記事が誰かの何かしらで役に立ってくれたら幸いです。 それでは、また。