Edge Impulseで作成した音声認識モデルをArduinoライブラリーとしてSpresenseで使う

## 要約
マイコン上で実行可能な機械学習モデルをノーコードで作成できるサービス[Edge Impulse](https://www.edgeimpulse.com/)で作った音声認識モデルを、[Spresense](https://developer.sony.com/ja/develop/spresense/)のArduino開発環境で実行してみました。

## Arduinoスケッチ
完成品のスケッチです。
<details><summary>スケッチ</summary><div>

```C

// If your target is limited in memory remove this macro to save 10K RAM
#define EIDSP_QUANTIZE_FILTERBANK   0

/*
 ** NOTE: If you run into TFLite arena allocation issue.
 **
 ** This may be due to may dynamic memory fragmentation.
 ** Try defining "-DEI_CLASSIFIER_ALLOCATION_STATIC" in boards.local.txt (create
 ** if it doesn't exist) and copy this file to
 ** `<ARDUINO_CORE_INSTALL_PATH>/arduino/hardware/<mbed_core>/<core_version>/`.
 **
 ** See
 ** (https://support.arduino.cc/hc/en-us/articles/360012076960-Where-are-the-installed-cores-located-)
 ** to find where Arduino installs cores on your machine.
 **
 ** If the problem persists then there's not enough memory for this model and application.
 */

/* Includes ---------------------------------------------------------------- */
#include <Audio.h>
#include <SDHCI.h>
#include <spresense_coot_inferencing.h> // Edge Impulseで作成したライブラリのヘッダー

SDClass SD;
AudioClass *theAudio = AudioClass::getInstance();

#define SR AS_SAMPLINGRATE_16000 // サンプリングレート（Edge Impulseの訓練で使ったのと同じものを使用）
#define WIN_SIZE 1 // ウィンドウサイズ。1秒。
#define BUFF_SAMPLES 256 // 一度に読み込む音声のサンプル数
#define THRESHOLD 0.5 // モデルの出力がこの値より大きければ「検出した」とみなす

// 1秒の音声を一度に読み込むことはできないので、こまめに読み込み、配列に保存する
const int buffering_time = 8; // 音声読み込みを行う間隔 (ms)
const int buffer_size = BUFF_SAMPLES * sizeof(int16_t); // 一度に読み込む音声のデータサイズ(バイト)

/** Audio buffers, pointers and selectors */
typedef struct {
    int16_t buffer[WIN_SIZE*SR]; // モデルに食わせるデータを入れるバッファー。
                                 // BUFF_SAMPLESずつ読み出された音声はこの中に逐次挿入され、一杯になったらモデルへ送られる。
    uint32_t buf_count; // 何バイト読み込んだか
} inference_t;

static inference_t inference;
static bool debug_nn = false; // Set this to true to see e.g. features generated from the raw signal

/**
 * @brief      Arduino setup function
 */
void setup()
{
    // put your setup code here, to run once:
    Serial.begin(115200);

    Serial.println("Edge Impulse Inferencing Demo");

    // summary of inferencing settings (from model_metadata.h)
    ei_printf("Inferencing settings:\n");
    ei_printf("\tInterval: ");
    ei_printf_float((float)EI_CLASSIFIER_INTERVAL_MS);
    ei_printf(" ms.\n");
    ei_printf("\tFrame size: %d\n", EI_CLASSIFIER_DSP_INPUT_FRAME_SIZE);
    ei_printf("\tSample length: %d ms.\n", EI_CLASSIFIER_RAW_SAMPLE_COUNT / 16);
    ei_printf("\tNo. of classes: %d\n", sizeof(ei_classifier_inferencing_categories) / sizeof(ei_classifier_inferencing_categories[0]));

    if (microphone_inference_start() == false) {
        ei_printf("ERR: Failed to setup audio sampling\r\n");
        return;
    }
    // LEDのセットアップ（ここでは、対象を検出したときに内蔵LEDを点滅させる）
    pinMode(LED0, OUTPUT);
    pinMode(LED1, OUTPUT);
    pinMode(LED2, OUTPUT);
    pinMode(LED3, OUTPUT);
}

/**
 * @brief      Arduino main function. Runs the inferencing loop.
 */
void loop()
{
    ei_printf("Recording...\n");
    digitalWrite(LED0, HIGH); // LED0だけ光っている=録音中
    microphone_inference_record();
    digitalWrite(LED0, LOW);
    ei_printf("Recording done\n");

    signal_t signal;
    signal.total_length = EI_CLASSIFIER_RAW_SAMPLE_COUNT;
    signal.get_data = &microphone_audio_signal_get_data;
    ei_impulse_result_t result = { 0 };

    EI_IMPULSE_ERROR r = run_classifier(&signal, &result, debug_nn); // モデル実行
    if (r != EI_IMPULSE_OK) {
        ei_printf("ERR: Failed to run classifier (%d)\n", r);
        return;
    }

    // print the predictions
    ei_printf("Predictions ");
    ei_printf("(DSP: %d ms., Classification: %d ms., Anomaly: %d ms.)",
        result.timing.dsp, result.timing.classification, result.timing.anomaly);
    ei_printf(": \n");
    double conf;
    for (size_t ix = 0; ix < EI_CLASSIFIER_LABEL_COUNT; ix++) {
        ei_printf("    %s: ", result.classification[ix].label);
        ei_printf_float(result.classification[ix].value);
        ei_printf("\n");
        if (result.classification[ix].label == "coot") // coot が検出対象
        {
          conf = result.classification[ix].value;
        }
    }
    // 対象をを検出したときの処理
    if (conf > THRESHOLD)
    {
      action();
    }
      
#if EI_CLASSIFIER_HAS_ANOMALY == 1
    ei_printf("    anomaly score: ");
    ei_printf_float(result.anomaly);
    ei_printf("\n");
#endif
}

// 対象を検出した際の処理
// LED0~4を順番に光らせる
static void action(void)
{
  Serial.println("Detected!! \n");
  for (int i = 0; i < 3; i ++)
  {
    digitalWrite(LED0, HIGH);
    delay(10);
    digitalWrite(LED0, LOW);
    digitalWrite(LED1, HIGH);
    delay(10);
    digitalWrite(LED1, LOW);
    digitalWrite(LED2, HIGH);
    delay(10);
    digitalWrite(LED2, LOW);
    digitalWrite(LED3, HIGH);
    delay(10);
    digitalWrite(LED3, LOW);
    delay(10);
  }
}

/**
 * @brief      PDM buffer full callback
 *             Copy audio data to app buffers
 */
static void get_audio_waveform(void)
{
  int read_size;
  static char buff[buffer_size];
  // buffer_sizeで要求されたデータをbuffに格納する
  // 読み込みできたデータ量（バイト）は read_size に設定される
  // 音声データはchar型で得られる
  int ret = theAudio->readFrames(buff, buffer_size, &read_size);
  // 読み込めたデータ量が一度に読み込むべき量より少なかった場合は、少し待つ
  if (read_size < buffer_size)
  {
    delay(buffering_time);
    return;
  }
  if (ret != AUDIOLIB_ECODE_OK && ret != AUDIOLIB_ECODE_INSUFFICIENT_BUFFER_AREA) 
  {
    Serial.println("Error err = " + String(ret));
    theAudio->stopRecorder();
    while(1);
  }
  // char型の音声データをint16にキャストする
  int16_t *samples = (int16_t *)buff;
  int r = read_size / sizeof(int16_t);
  for (int i = 0; i < r; i++) 
  {
    // inference.bufferがいっぱいになるまで音声データを挿入
    inference.buffer[inference.buf_count+i] = samples[i]; // 何番目までデータが入っているかはinference.buf_countに保存される
    inference.buf_count++;
    if ((inference.buf_count) == (WIN_SIZE * SR))
    {
      break;
    }
  }
}

static void microphone_inference_record(void)
{  
   theAudio->startRecorder(); // 録音開始
      while(inference.buf_count < (WIN_SIZE * SR)) // inference.bufferがいっぱいになるまで録音
      {
        get_audio_waveform();
      }
      inference.buf_count = 0;
  theAudio->stopRecorder();
}


static bool microphone_inference_start()
{
    inference.buf_count  = 0;
    while (!SD.begin()) { Serial.println("Insert SD card"); }
    Serial.println("Init Audio Recorder");
    theAudio->begin();
    // 入力をマイクに設定
    theAudio->setRecorderMode(
      AS_SETRECDR_STS_INPUTDEVICE_MIC,
      220); //ゲイン。https://github.com/edgeimpulse/firmware-sony-spresense/blob/e3f4a9431d3754a4910619fd2afa46cc30bf2a98/main.cpp では220になっていた。
      
    // 録音設定：フォーマットはPCM (16ビットRAWデータ)、
    // DSPコーデックの場所の指定 (SDカード上のBINディレクトリ)、
    // サンプリグレート 16000Hz、モノラル入力
    int err = theAudio->initRecorder(AS_CODECTYPE_PCM 
      ,"/mnt/sd0/BIN", SR, AS_CHANNEL_MONO);                             
    if (err != AUDIOLIB_ECODE_OK) {
      Serial.println("Recorder initialize error");
      while(1);
    }
    return true;
}

/**
 * Get raw audio signal data
 */
// モデルはこの関数を使って音声データを取得する
static int microphone_audio_signal_get_data(size_t offset, size_t length, float *out_ptr)
{
    //numpy::int16_to_float(&inference.buffer[offset], out_ptr, length);
    // int16をfloatに変換
    arm_q15_to_float(&inference.buffer[offset], out_ptr, length);
    return 0;
}

/**
 * @brief      Stop microphone
 */
static void microphone_inference_end(void)
{
    theAudio->stopRecorder();
    ei_free(inference.buffer);
}

#if !defined(EI_CLASSIFIER_SENSOR) || EI_CLASSIFIER_SENSOR != EI_CLASSIFIER_SENSOR_MICROPHONE
#error "Invalid model for current sensor."
#endif
```
</div></details>

## 背景等
### はじめに：なぜエッジコンピューティングが重要か
>[The future of machine learning is tiny](https://www.oreilly.com/radar/the-future-of-machine-learning-is-tiny/)
Pete Warden, Google TensorFlow Group

　近年、クラウドコンピューティングやIoTが脚光を浴びています。一方、画像や音声といった大容量のデータを扱う際や、通信環境が貧弱な現場では、その場でデータ解析を行うエッジコンピューティングも重要な技術です。私は大学院で野鳥や鳴く虫の鳴き声を自動検出するデバイスを開発しているのですが、この場合でも電力消費やコストの問題から、クラウドコンピューティングの利用は非現実的です。
　エッジコンピューティングというと、Raspberry PiやJetson nanoのようなシングルボードコンピュータを思い浮かべる方が多いかもしれません。しかし、これらには消費電力が大きいという致命的な問題があります。安いノートPC並みの性能をもつこれらをバッテリーやソーラーで運用することは、多大な困難を伴います。
　そこで登場したのが、Arduino等の安くて省電力なマイコンでTensorFlowの推論を動かすためのフレームワーク[TensorFlow Lite for Microcontrollers](https://www.tensorflow.org/lite/microcontrollers?hl=ja)です。しかし、RaspberryPi等と比べて圧倒的にハードウェアが乏しく、C/C++での開発が必須なマイコン上での機械学習の実行は、素人である私にとってこれまでハードルの高いものでした。少なくとも、Edge Impulseが登場するまでは。
### Edge Impulse
　Edge Impulseはマイコン上で動く機械学習モデルの開発を簡単に行うことができる素晴らしいサービスです。Edge Impulseでは、実機のセンサーを使ったデータ収集、特徴量エンジニアリング、モデルの設計と訓練、実機へのデプロイを、一貫してノーコードで行うことができます。[公式に対応しているデバイス](https://docs.edgeimpulse.com/docs/development-platforms/fully-supported-development-boards)も豊富で、有名なものだとSpresense、ArduinoNano、ESP32、RaspberryPi Pico等が挙げられます。さらに、作成したモデルをArduinoやC++のライブラリとして出力することができるため、公式にはサポートされていないデバイスでも使える場合があります。料金設定もかなり良心的で、1ジョブの実行時間が20分を超えず、かつ訓練データが4GBを超えない限りは無料で使うことができます。
　Edge Impulseの優れた点は多数ありますが、特に、実機からボタン一つでデータを収集し、すぐさま訓練に反映させられる点は大きなメリットです。これによって、モデルの開発と試験のサイクルを飛躍的に速くすることができます。試験中にマイコンが誤判別をしたなら、すぐにその状況でデータを取得して訓練をやり直せばいいのです。
　Edge Impulseの使い方については、記事（[例](https://qiita.com/jksoft/items/2baff29650cab019b968)）や[公式のドキュメント](https://docs.edgeimpulse.com/docs/)が充実しているため、ここでは割愛します。この記事で実装した鳥の鳴き声の自動判別モデルは、[音声認識のチュートリアル](https://docs.edgeimpulse.com/docs/tutorials/audio-classification)を参考に開発しました。
### Spresense by Sony
　エッジコンピューティングで機械学習を行う大きなモチベーションの一つが、消費電力を減らしたいというものでした。一方、マイクやカメラからのデータを逐次処理し、それを用いて推論を行い、推論結果をディスプレイ等に出力するためには、それなりの計算資源が必要になります。この2つを高い水準で両立しているマイコンが、Sonyの[Spresense](https://developer.sony.com/ja/develop/spresense/)です。SpresenseはArm Cortex M4Fを6つ搭載したSonyオリジナルのプロセッサーCXD5602を搭載しており、CPUコア/メモリタイル単位の給電制御等の技術によってマルチコアでありながら高い省電力性能を誇っています。
　Spresenseの別の特徴として、充実した周辺機器とArduino開発環境が挙げられます。例えば、4チャンネルのマイク入力やハイレゾ再生、写真撮影、導き対応のGPSを使ったアプリケーションを、慣れ親しんだArduinoIDEで開発することができます。また、各CPUコアごとにスケッチを用意することで、手軽にマルチコアプログラミングができる、といった機能も面白いと思いました。さすがSonyだけあって、[日本語の公式ドキュメント](https://developer.sony.com/develop/spresense/docs/arduino_set_up_ja.html)も大変充実しています。
### Neural Network Console...?
　ここまで、Edge ImpulseとSpresenseの解説をしてきましたが、実はSonyはノーコードでニューラルネットが訓練できるサービス[Neural Network Console](https://dl.sony.com/ja/)を用意しており、Spresneseとの連携も当然可能で、そのための[解説本](https://www.ohmsha.co.jp/book/9784873119670/)まで出ています。ちなみにこの本も買ったのですが、Spresenseでカメラやマイクを使ったマルチコアプログラミングの勉強をする上でとても参考になりました。
　それでもNeural Network ConsoleではなくEdge Impulseを使った理由は、データの収集が圧倒的に楽だからです。Neural Network Consoleでは特徴量抽出の実装まではやってくれないため、まずArduinoでモデルに食わせるデータ（音声認識ならスペクトログラムやMFCC）を作成するプログラムを書く必要があります。一方、Edge Impulseではクリック一つでパソコンに繋いだSpresenseから音声を収集することができ、スペクトログラムやMFCCへの変換も自動でやってくれるのです。
### Spresense SDK...?
Edge Impulseでモデルを作成することができたら、実際に作りたいアプリケーション（特定の音声を認識したら時刻を記録する等）の上に実装する必要があります。Edge ImpulseはSpresenseに公式対応しているのですが、[ドキュメント](https://docs.edgeimpulse.com/docs/deployment/running-your-impulse-locally/running-your-impulse-spresense)を見てみると、ArduinoではなくSpresense SDKを使え、と書いてあります。Spresenseの大きなメリットの一つがArduino環境で手軽に開発できる点だったのに、C++がほとんど書けない私にとって、これは致命的な問題です。そこで、Edge Impulse で作成したモデルをArduinoライブラリとして吐き出し、Spresenseで使うことにチャレンジしてみました。
### TODO
モデルは無事動いたのですが、[Edge Impulseで作ったバイナリ](https://docs.edgeimpulse.com/docs/development-platforms/officially-supported-mcu-targets/sony-spresense)を動かしたときと比べて、データの下処理(DSP、私のモデルだとハイパス、ローパスフィルタとMFCC)に倍以上の時間がかかっていました。今回作成したスケッチではSpresenseのマルチコア性能を活かせていないことが原因だと思われます。今後は音声の取得を別のコアで行うことで高速化できないか試してみるつもりです。また、小さい音への反応がバイナリを動かしたときより少し悪いような気がするので、これも原因を探っています。