ARMのCMSIS-DSPライブラリで実数FFT

前回の記事のつづき

ESP32ではesp_dspライブラリが利用できるが、ARMではCMSIS-DSPライブラリが利用できる。

使い方

#include <arm_math.h>
arm_rfft_fast_instance_f32 S;
arm_status status = arm_rfft_fast_init_f32(&S, nfft);
arm_rfft_fast_f32(&S, data, data, 0);

検証

前回と同じ計算をARMのCMSIS-DSPライブラリを使って実行してみる。実験にはSPRESENSEを用いた。

#include <Arduino.h>
#include <math.h>
#include <arm_math.h>

#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif

// FFT構造体
arm_rfft_fast_instance_f32 S;

// fs: サンプルレート [Hz]
// f: 波形の周波数 [Hz]
// i: サンプル番号
// 戻り値: サンプル値 (-1.0 〜 +1.0)
double my_wave(double fs, double f, int i)
{
    double t = (double)i / fs; // 時刻 [秒]
    double x = cos(2.0 * M_PI *     f * t) 
       + 0.5 * sin(2.0 * M_PI * 2 * f * t)
       + 0.25 * cos(2.0 * M_PI * 3 * f * t);
    return x;
}

void setup() {

  Serial.begin(115200);
  Serial.println("Hello!");

  const double fs = 16000.0; // サンプルレート[Hz]
  const double f = 1000.0;   // 波形の周波数[Hz]

  const int nfft = 512;
  
  arm_status status = arm_rfft_fast_init_f32(&S, nfft);
  if (status != ARM_MATH_SUCCESS) {
    Serial.printf("DSP init error\n");
    return;
  }

  float data[nfft];  // 入力は実数の配列

  for (int i = 0; i < 512; i++) {
    double x = my_wave(fs, f, i);
    data[i] = x;
  }

  arm_rfft_fast_f32(&S, data, data, 0);
    
  //dsps_fft4r_deinit_fc32();

  for (int i = 0; i < 256; i++) {
    Serial.printf("[%d] %.2f %.2f\n", i, data[2 * i], data[2 * i + 1]);
  }
}

void loop() {
  delay(1000);
}

これを実行すると、前回と同等の結果が得られた。

[32] 256.00 -0.00
[64] 0.00 -128.00
[96] 64.00 0.00

実数列を半分サイズの複素FFTにつっこむイディオム

謎のコード

ESP32の音声処理のプログラムに次のようなコードがあった。

  dsps_fft4r_fc32(data, nfft / 2);
  dsps_bit_rev4r_fc32(data, nfft / 2);
  dsps_cplx2real_fc32(data, nfft / 2);

dataは音声のサンプル列つまり実数の配列であり、nfft はFFTサイズである。これは一見して奇妙である。dsps_fft4r_fc32 は複素FFTの関数であって、第一引数は 実部と虚部が交互に並んだ配列を、第二引数にはFFTサイズを要求される。それなのに実数の配列と半分のFFTサイズを渡している。これはどうしたことか?
また、dsps_bit_rev4r_fc32 はバタフライ演算結果の並べ直しと思われるが、dsps_cplx2real_fc32 はいったい何なのか? ドキュメントを読んでもよく分からない。

Gemini先生の解説

Gemini先生によると、「N点の実数FFTを、N/2点の複素FFTとして計算することで、計算量とメモリを半分に節約する」というテクニックらしい。

  • N点の実数データを、「偶数番目を実部、奇数番目を虚部」と見なして、N/2点の複素データとして FFT につっこむ。(dsps_fft4r_fc32関数)
  • バタフライ演算結果を並べ直す。(dsps_bit_rev4r_fc32関数)
  • N/2点の複素FFT結果を、N点の実数FFT結果へと換算する。(dsps_cplx2real_fc32関数)

ということらしく、dsps_cplx2real_fc32関数がこのトリックの肝なのだが、計算式を見てもよく分からず、ホンマかいな?という気持ちが拭えない。

検証

PCで実数FFT

まず、PC上で実数FFTを計算してみる。FFTの計算にはKiss FFTを利用した。

  • サンプリング周波数 : 16kHz
  • 入力波形 : 1kHzと2kHzと3kHzの正弦波の合成波形
  • FFT点数 : 512点
#include <stdio.h>
#include <math.h>
#include "kiss_fftr.h"

#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif

// fs: サンプルレート [Hz]
// f: 波形の周波数 [Hz]
// i: サンプル番号
// 戻り値: サンプル値 (-1.0 〜 +1.0)
double my_wave(double fs, double f, int i)
{
    double t = (double)i / fs; // 時刻 [秒]
    double x = cos(2.0 * M_PI *     f * t) 
       + 0.5 * sin(2.0 * M_PI * 2 * f * t)
       + 0.25 * cos(2.0 * M_PI * 3 * f * t);
    return x;
}

int main(void)
{
    const double fs = 16000.0; // サンプルレート[Hz]
    const double f = 1000.0;   // 波形の周波数[Hz]

    FILE* fp = fopen("fftout.txt", "w");

    const int nfft = 512;
    kiss_fftr_cfg cfg = kiss_fftr_alloc(nfft, 0, NULL, NULL);

    float in[nfft];                 // 入力は実数の配列
    kiss_fft_cpx out[nfft / 2 + 1]; // 出力は (nfft/2 + 1) 個の複素数

    for (int i = 0; i < 512; i++) {
        double x = my_wave(fs, f, i);
        in[i] = x;
    }
    kiss_fftr(cfg, in, out);
    
    kiss_fftr_free(cfg);

    for (int i = 0; i <= 256; i++) {
        fprintf(fp, "[%d] %.2f %.2f\n", i, out[i].r, out[i].i);
    }

    fclose(fp);

    return 0;
}

これを実行すると、[32]と[64]と[96]にピークが出る。Δf = 16kHz / 512 = 31.25Hz であるから、32×31.25Hz = 1kHzで計算が合う。cosが実部に、sinが虚部に出ていることにも注意。

[32] 256.00 0.00
[64] 0.00 -128.00
[96] 64.00 0.00
ESP32の問題のコード

今度は同じ計算をESP32の問題のコードで実行してみる。実験にはM5Stack Core2を用いた。

#include <Arduino.h>
#include <math.h>
#include <esp_dsp.h>

#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif

// fs: サンプルレート [Hz]
// f: 波形の周波数 [Hz]
// i: サンプル番号
// 戻り値: サンプル値 (-1.0 〜 +1.0)
double my_wave(double fs, double f, int i)
{
    double t = (double)i / fs; // 時刻 [秒]
    double x = cos(2.0 * M_PI *     f * t) 
       + 0.5 * sin(2.0 * M_PI * 2 * f * t)
       + 0.25 * cos(2.0 * M_PI * 3 * f * t);
    return x;
}

void setup() {

  Serial.begin(115200);
  Serial.println("Hello!");

  const double fs = 16000.0; // サンプルレート[Hz]
  const double f = 1000.0;   // 波形の周波数[Hz]

  const int nfft = 512;

  if (dsps_fft4r_initialized != 0)
  {
    Serial.printf("DSP is already initialized\n");
    return;
  }
  if (dsps_fft4r_init_fc32(NULL, nfft / 2) != ESP_OK)
  {
    Serial.printf("DSP init error\n");
    return;
  }

  float data[nfft];  // 入力は実数の配列

  for (int i = 0; i < 512; i++) {
    double x = my_wave(fs, f, i);
    data[i] = x;
  }

  dsps_fft4r_fc32(data, nfft / 2);
  dsps_bit_rev4r_fc32(data, nfft / 2);
  dsps_cplx2real_fc32(data, nfft / 2);
    
  dsps_fft4r_deinit_fc32();

  for (int i = 0; i < 256; i++) {
    Serial.printf("[%d] %.2f %.2f\n", i, data[2 * i], data[2 * i + 1]);
  }
}

これを実行すると、細かい誤差はあれ、PCでの実数FFTと同等の結果が得られた。

[32] 256.00 -0.00
[64] 0.00 -128.00
[96] 64.00 -0.00

じつは Kiss FFT も!

Kiss FFTのソースを読むと、じつはKiss FFTも内部ではESP32と同じく、実数列を半分サイズの複素FFTにつっこんで結果を換算するという処理をしてるっぽい。数学的背景がいまひとつよく分からないが、とりあえず計算結果が正しいことは確認できたし、イディオムだと思っておくことにする。

若い頃に数学を真面目にやらなかった報いであるが、しかし今はもう泥縄的に数学を深掘りできるほど若くはないのである。。。

簡易的なノイズ抑制と発話検出

C++で実装。ただし、音声データは符号付き16ビットPCMとし、ひとまず計算にはfloat型を用いて処理の重さも考慮しないものとします。

ノイズ抑制 (Noise Suppression)

  • 一般的には、窓関数→FFT→ノイズ推定→減算処理→IFFT→重ね合わせ
  • しかしやってみるとなかなかうまくいかない。逆にミュージカルノイズ(ピョロピョロ音)が出る。
  • なるべくホワイトノイズを抑えて人の声を通すことだけを考える。
  • バンドパスフィルタ(HPF+LPF) + 簡単なノイズゲート くらいでよいのではないか。
  • 人の声の主成分は300〜3400 Hz くらいなので 200〜4000 Hz くらいを通すようにする。

 → NoiseSuppressor.h · GitHub : NoiseSuppressorクラスを実装。

発話検出 (Voice Activity Detection)

  • 簡易的な方法としては振幅とゼロクロス数を用いる。
  • 振幅の平均レベルが一定以上 かつ ゼロクロスが毎秒400~8000 (200〜4000 Hz) なら発話と判定。

 → SimpleVAD.h · GitHub : SimpleVADクラスを実装。

実験

  • とりあえずPC上で実験
  • サンプリング周波数16000Hz、符号付き16ビットPCM、モノラルのWAVファイルを使用する。
  • 10msec (=160サンプル=320バイト) ずつ処理する。
  • ノイズ抑制した音声データをファイルに出力する。
  • 10msecごとの平均振幅、ゼロクロス数、発話判定をテキストファイルに出力する。
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include "NoiseSuppressor.h"
#include "SimpleVAD.h"

int main(void)
{
    uint8_t buf[320];

    FILE* fp = fopen("input.wav", "rb");
    if (!fp) {
        perror("fopen 1");
        return -1;
    }
    FILE* ofp = fopen("output.wav", "wb");
    if (!fp) {
        perror("fopen 2");
        return -2;
    }
    FILE* log = fopen("log.txt", "w");
    if (!fp) {
        perror("fopen 3");
        return -3;
    }

    // 先頭 44 バイトをコピー (WAV ヘッダ)
    size_t n = fread(buf, 1, 44, fp);
    if (n < 44) {
        fprintf(stderr, "source file too small\n");
        fclose(fp);
        fclose(ofp);
        return -4;
    }
    fwrite(buf, 1, 44, ofp);

    NoiseSuppressor ns(16000.0f);
    SimpleVAD vad(16000, 1);

    while (1) {
        size_t n = fread(buf, 1, 320, fp);
        if (n < 320) {
            // 最後の端数のデータはゼロパディング
            memset(buf, 0x00, n);
            fwrite(buf, 1, n, ofp);
            printf("end of file!");
            break;
        }
        else {
            // ノイズ抑制処理
            ns.process((int16_t*)buf, (int16_t*)buf, 160);
            // 発話検出処理
            bool ret = vad.process((int16_t*)buf, 160);
            fwrite(buf, 1, 320, ofp);
            fprintf(log, "%d, %d, %d\n", vad.amp, vad.zcr, vad.isSpeech ? 100 : 0);
        }
    }
    fclose(ofp);
    fclose(fp);
    fclose(log);

    return 0;
}

AIスタックチャンのWakeWord.cppを解読 (2)

前回の続きです。

simplevox::VadEngine

デフォルトの設定は下記の通り

  • サンプルレート = 16000 Hz
  • フレーム時間 = 10 msec
  • フレーム長 = フレーム時間 × サンプルレート = 160 サンプル
  • VADモード = レベル0 (レベル0~4, 高いほど判定が厳しい)
  • hangbefore_ms = 100 msec (音声区間に含まれる音声検出前の時間)
  • decision_time_ms = 200 msec (音声あり判定がこれ以上続くと音声区間と判断)
  • hangover_ms = 200 msec (音声区間として含まれる音声終了後の時間)


simplevox::MfccEngine

デフォルトの設定は下記の通り

  • サンプルレート = 16000 Hz
  • フレーム時間 = 32 msec
  • フレーム長 = フレーム時間 × サンプルレート = 512 サンプル
  • ホップ長 = フレーム長さ / 2 = 256 サンプル
  • FFTデータ点数 = 512
  • メルフィルタバンクのチャンネル数 = 24
  • MFCC係数の数 = 12
  • プリエンファシス係数 = 97 % (高域成分強調)

esp_ns (ノイズ抑制)

  • ns_pro_create(フレーム時間[msec], レベル, サンプルレート[Hz]) → ハンドル
    • レベルは 0 ~ 2
  • ns_process(ハンドル, 入力データ, 出力データ)
  • ns_destroy(ハンドル)

esp_vad (音声アクティブ検出)

  • vad_create(レベル) → ハンドル
    • モード 0 ~ 4 (高いほど判定が厳しい)
  • vad_process(ハンドル, データ, サンプルレート[Hz], サンプル時間[msec] ) → 状態
    • 状態 : 沈黙 | 発話
  • vad_destroy(ハンドル)

esp_dsp

  • dsp_is_power_of_two( ) → bool : 2のn乗か否かをチェック
  • float型の基数4の複素FFT (4R FFT)
    • dsps_fft4r_init_fc32(バッファ, FFTデータ点数 / 2) : 初期化処理
    • dsps_fft4r_fc32(データ, FFTデータ点数 / 2) : 複素FFT演算
    • dsps_bit_rev4r_fc32(データ, データ点数) : バタフライ演算によるFFT結果の並べ直し
    • dsps_cplx2real_fc32(データ, データ点数) : 入力が実数列の場合のFFT結果の変換
    • dsps_fft4r_deinit_fc32() : 終了処理

AIスタックチャンのWakeWord.cppを解読

AIスタックチャンのウェイクワード検出のソースである WakeWord.cpp をざっくり解読します。
コメントにある通り、ややこしいです。

仕様・用語

  • サンプルレート: 16000Hz
  • VAD : 音声アクティビティ検出 (人が話していることを検出)
  • MFCC : メル周波数ケプストラム係数 (音声の特徴量)
  • DTW距離 : 2つの時系列データの似てない度 (伸縮は補正する)
  • M5Unifiedライブラリに依存
  • SimpleVoxライブラリに依存
  • ESP32固有のライブラリに依存

主要なAPI

以下の3つが主要なAPI (公開関数) です。

wakeword_init() : 初期化
  • rawAudio (ウェイクワード登録用バッファ) に3秒ぶんのメモリを確保
  • rxBuffer (マイク入力用バッファ) にVADフレーム長×3個ぶんのメモリを確保
  • rawBuffer (ウェイクワード比較用バッファ) に MFCCフレーム長+VADフレーム長 ぶんのメモリを確保
  • features (MFCC用バッファ) にMFCCフレーム数×MFCC係数の個数 ぶんのメモリを確保
  • ノイズ抑制器を生成
  • 登録済みのウェイクワードのMFCCをSPIフラッシュメモリからロード
wakeword_regist() : ウェイクワードの登録
  • マイクからVADフレームぶんの音声を rxBuffer へ
  • ノイズ抑制器に通す
  • VADで1発話ぶんの音声を rawAudio にためる
  • MFCCを算出し、ウェイクワードのMFCCとする
  • ウェイクワードのMFCCをSPIフラッシュメモリに保存
wakeword_compare() : ウェイクワードの比較
  • マイクからVADフレームぶんの音声を rxBuffer へ
  • ノイズ抑制器に通す
  • VADで発話音声を rawBuffer にためる
  • MFCCフレーム長ごとにMFCCを算出して features にためる
  • 発話の終わりを検出するか、最大MFCCフレーム数に達したら判定へ
    • MFCCを正規化
    • ウェイクワードと今回の発話のMFCCのDTW距離を算出
    • 180未満であれば一致と判定

依存しているESP32固有のライブラリ

  • esp_heap_caps : 動的メモリ確保 (標準関数のmalloc等と異なり、メモリ種別を選べる)
  • esp_dsp : DSPライブラリ
  • esp_ns : ノイズ抑制
  • esp_vad : VAD (音声アクティビティ検出)

SimpleVoxライブラリ

  • simplevox::VadEngine : VADエンジン ( esp_vad に依存)
  • simplevox::MfccEngine : MFCCエンジン ( esp_dsp に依存)
  • simplevox::calcDTW : DTW距離を算出

メモ:スタックチャンに関するまとめ (3)

現状はラジコンでしかない センスチャン を、どんなロボットにしていくか。

まずは本家である スタックチャン の機能を調査しました。

Moddable版ファームウェア (ししかわさん版)

  • Renderer: 顔の描画
  • Driver: モータ等の駆動
  • TTS: 音声合成
    • 事前生成 : ビルド時に生成した音声をスタンドアロンで再生
    • リモート : オンラインでTTSサーバに生成させた音声をストリーム
    • Google Cloud Text-to-Speech API, Coqui AI TTS, VoiceVoxなどに対応


AIスタックチャン (robo8080さん版)

  1. M5Stack内蔵マイクでウェイクワードを認識し音声認識を起動
  2. Google Cloud TTS または OpenAI Whisperで音声認識
  3. 音声認識結果をChatGPTへ送り応答を生成
  4. Web版VoicevoxでChatGPTの応答を音声合成
  5. M5Stackーavatarで顔の表示及び音声合成に合わせてリップシンク
  6. サーボモーター2軸でスタックチャンのパン/チルトを制御
  • 音声認識→応答を生成→音声合成 をWeb上のサービスを利用して実現している。
  • ウェイクワードの検出はエッジ側(M5Stack)でおこなっている。 (MFCC=メル周波数ケプストラム係数)
  • リップシンクは、再生中の音声レベルに比例して口を開ける。(Avatar::setMouthOpenRatio())


stackchan-arduinoライブラリ

  • 設定ファイルの読み込み機能 : SDカードからYAML形式の各種設定ファイルを読み込む
  • サーボのコントロール機能 : 3種類のサーボを設定で切り替えて使用可


で、センスチャン どうするか?

  • センスチャン はArduinoベースなので、主に参考にするのは AIスタックチャン および stackchan-arduinoライブラリ
  • Avatar::getGaze()に応じたパン/チルトの代わりに、車輪でうろちょろする。(まずはこれ)
  • ウェイクワード検出はしたい。(まずはAIスタックチャン相当の移植。SPRESENSEならもっと頑張れる?)
  • 野望としてはローカルでの音声合成。(AquesTalk pico がAVRで動くのだから理屈上は可能のはず。)
    • 日本語の読み方は辞書が必要でローカルでは難しそう。それらをリモートに頼るとしても文字データだけで済むから通信量は少なくて済むはず。
    • VoiceVoxみたいなのはスペック的にも到底無理。目指すのは AquesTalk pico のような方向性。
    • AquesTalk pico LSI外付けがお手軽だが、SPRESENSEでそれやるのは敗北感。

メモ:スタックチャンに関するまとめ (2)

以前にもまとめましたが、自分でも整理できてなかったので別の切り口でまとめ直します。

Special Thanks to

ハードウェア

  • ししかわ版 : ししかわさんが設計した筐体基板 (アールティ版もこの系統)
  • タカオ版 : タカオさんが設計した筐体 と 秋田先生が設計したStack-chan_Takao_Base基板 (Grove接続)

サーボ

  • SG90系 : 廉価なPWMサーボ。普及版 / 初心者向き。
  • SCS0009 : 比較的廉価なシリアルサーボ。
  • XL330 : Dynamixelのシリアルサーボ。

ソフトウェア