ESP32とMAX4466を用いた簡易周波数解析器の製作


# 0.はじめに

こんにちは！Kabosuxuです。

先日趣味の一環でこのようなインタラクティブアート作品を作りました。
<iframe width="560" height="315" src="https://www.youtube.com/embed/Xe8UOkxdNpE" frameborder="0" allowfullscreen></iframe>



Bluetoothスピーカから流れる音楽の周波数を解析し、
その結果に応じてハーバリウムを発光させるものです。

当記事ではそのアート作品に適用した、簡易周波数解析器の技術的説明を行います。
以下の方々に役立てれば幸いです🙇

 - 音楽を用いた電子工作をしたい方
 - 音声処理に興味がある方
 - 音声を周波数毎に分離する技術「フーリエ変換」について知りたい方
 - フーリエ変換をArduino(厳密にはESP32)で実装・検証したい方

Arduino初心者～中級者向けの記事です。

# 1.動作原理

## 1-1. フーリエ変換の基本的原理

**フーリエ変換(FT:Fourier Transform)** は時間変化する信号を周波数毎に信号分離する技術です。

![フーリエ変換.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2632063/3345149a-8c7c-636d-db50-f814878325af.png)
>[NTi Audioホームページ 測定ノウハウ/高速フーリエ変換](https://www.nti-audio.com/ja/%E3%82%B5%E3%83%9D%E3%83%BC%E3%83%88/%E6%B8%AC%E5%AE%9A%E3%83%8E%E3%82%A6%E3%83%8F%E3%82%A6/%E9%AB%98%E9%80%9F%E3%83%95%E3%83%BC%E3%83%AA%E3%82%A8%E5%A4%89%E6%8F%9B) より引用

Arduinoなどマイコンで音声処理する場合、
アナログ信号$\{f(x)}$から有限長のディジタル信号への変換が必要です。
その場合、**離散フーリエ変換**(DFT:Discrete Fourier Transform)を使用します。
```math
C_k = \sum_{n=0}^{N-1}f(x)e^{\frac{-i2 \pi ft}{N}}
```
ここで$\{N}$＝サンプル数、$\{f}$=周波数[Hz]、$\{t}$=時刻[s]です。

このDFTを高速化したものが **高速フーリエ変換(FFT:Fast Fourier Transform)** です。
詳細な原理は下記のサイトがわかりやすかったです。

http://www7b.biglobe.ne.jp/~yizawa/InfSys1/basic/chap7/index.htm

## 1-2.音を光で表現範囲
FFTにて音声を周波数成分毎に分離後、その分析結果を表現します。
今回はRGBカラーLEDテープを使って分析結果を表現します。
一般的に楽器毎の周波数範囲は下記のサイトに掲載されていました。

https://masatsumu-dtm.com/word_23-frequency

以上から、0~2kHzの範囲を6分割すれば楽器の周波数成分を概ね表現可能だと考えました。

分割した結果は下表のとおりです。

| 名称 | 周波数範囲　| 色 |
|:-:|:-:|:-:|
|低音域| 0  ~ 100  Hz | 紫　|
|中音域1| 101  ~ 250  Hz | ピンク　|
|中音域2| 251  ~ 500  Hz | 水色　|
|中音域3| 501  ~ 1000 Hz | 赤　|
|高音域1| 1001  ~ 1500 Hz | 橙　|
|高音域2| 1501  ~ 2000 Hz | 黄　|

## 1-3.パラメータ決め

サンプル数$\{N}$とサンプリング周波数$\{f_s}$を決めることで、各パラメータが決まります。
今回は音楽に合わせてLEDを発光させることを目的として、 $\{N}$:2048、$\{f_s}$:10000Hzとします。
これにより定まった各パラメータは下表の通りです。

| パラメータ | 式 | 値 |
|:-:|:-:|:-:|
|$\{f_n}$ ナイキスト周波数[Hz]:| $\frac{f_s}{2}$ | 5000[Hz]|
|$\{T_s}$:サンプリング周期[s]          |$\frac{1}{f_s}$ |100[us]|
|$\{df}$:周波数分解能[Hz]    |$\frac{1}{N T_s}$|4.8828125[Hz]|
|$\{D}$:時間窓 [s]  |$ N Ts$|0.2048[s]|

ここで注意なのが$\{D}$と$\{df}$のバランスです。
今回要求する周波数帯域が2000kHz狭いからといって、あまりにも$\{f_s}$を小さく設定すると$\{T_s}$と$\{D}$の値が大きくなり、リアルタイム性に欠けてしまいます。

## 1-4.フローチャート
以上を踏まえ、各動作をフローチャートとしてまとめると下図の通りになります。

![フローチャート.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2632063/80312882-294c-4a7d-9c7f-fc8f11bfe47b.png)

# 2.ハードウェア
## 2-1.使用部品
先述の動作原理を満たす部品として、以下を選定しています。
| 部品 | 役割 | 個数 | データシート |
|:-:|:-:|:-:|:-:|
|ESP32 DevKitC V4 |マイコン|1|[リンク](https://akizukidenshi.com/download/ds/espressifsystems/esp_wroom_32_datasheet_en.pdf)|
|MAX4466          |音声入力モジュール |1|[リンク](https://cdn-shop.adafruit.com/datasheets/MAX4465-MAX4469.pdf)|
|SK6812           |RGBカラーLEDテープ| 6 pix|[リンク](https://akizukidenshi.com/download/ds/dongguangopsco/sk6812.pdf)


筐体は揖保乃糸空き箱に防腐加工したものを採用しています。
Arduino Unoの場合、メモリが不足する可能性があります。

# 2-2.回路図
回路図というより配線図ですね...
マイコンにはマイクモジュールから信号が1つ、
カラーLEDに信号が6つ出力される形です。
またマイクモジュールおよびカラーLEDの電源をESP32の5v出力ピンで動作させたところ、1kHzのノイズがかなり高いレベルで検出されました。
そこで音声モジュールの電源は3.3Vにしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2632063/83af92c5-91f8-96eb-c616-fb5426c7a287.png)


最終的にはこのような形に組み上がりました。
外側
![1D9F3F82-DA10-4D4B-9540-64E9879935CE.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2632063/366c865d-67fa-3419-bbb8-3977a8d431d4.jpeg)

内側
![CDA08635-B86E-4B45-9B30-0A18424DBD52.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2632063/852e3954-ef19-d2de-ed23-9b59c8abb79d.jpeg)


# 3.ソースコード
ソースコードはGitHubで公開しています。

https://github.com/kabosuxu/Beatlium

プログラムを図式化すると下図の通りです。

![プログラム構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2632063/09d3c524-d68b-d980-6875-3086d6d3ec87.png)


ユーザ関数の(7)sampleと(8)DCRemoval2は下記のサイトを参考にしました。

https://ambidata.io/samples/m5stack/sound/

## 3-1.変数およびクラス宣言部分

```
/*
 * 2022/05/08(Sun)
 * Developper:Kabosuxu
 * Opensource
 */

#include <arduinoFFT.h>                                  
#define MIC 35                                                              // InputDevice(MAX4466) is conneccted to 35 Pin.
/* Name  Frequency      Color   Array  PinNo(ESP32)
 *  
 *  
 */
#define fftsamples 2048
#define SAMPLING_FREQUENCY 10000
double vReal[fftsamples];
double vImag[fftsamples];
arduinoFFT FFT = arduinoFFT(vReal, vImag, fftsamples, SAMPLING_FREQUENCY);

#include <Adafruit_NeoPixel.h>
const int PIN[6] = {14, 27, 26, 25, 33, 32};                               // OutputDevice(SK6812) is conneccted to 14,27,26,25,33,32 Pin.
/* Name  Frequency      Color   Array  PinNo(ESP32)
 *  LOW   1    ~ 100  Hz Purple  PIN[0] 14
 *  MID1  101  ~ 250  Hz Pink    PIN[1] 27
 *  MID2  251  ~ 500  Hz Cyan    PIN[2] 26
 *  MID3  501  ~ 1000 Hz Red     PIN[3] 25
 *  HIGH1 1001 ~ 1500 Hz Orange  PIN[4] 33
 *  HIGH2 1501 ~ 2500 Hz Yellow  PIN[5] 32
*/
#define NUMPIXELS 1
int color[6][3] = {{255, 0, 255},                                          // Purple
                   {255, 123, 123},                                        // Pink 
                   {0, 255, 255},                                          // Cyan
                   {255, 0, 0},                                            // Red
                   {255, 123, 0},                                          // Orange
                   {255, 255, 0}};                                         // Yellow

Adafruit_NeoPixel pixels_LOW(NUMPIXELS, PIN[0], NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel pixels_MID1(NUMPIXELS, PIN[1], NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel pixels_MID2(NUMPIXELS, PIN[2], NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel pixels_MID3(NUMPIXELS, PIN[3], NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel pixels_HIGH1(NUMPIXELS, PIN[4], NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel pixels_HIGH2(NUMPIXELS, PIN[5], NEO_GRB + NEO_KHZ800);

double avg[6] ={0.0, 0.0 , 0.0 , 0.0 , 0.0 , 0.0};                         // SoundPower average datata
```

## 3-2.setup部
setup部では、具体的には以下を行います。
- シリアルモニタ　SK6812　MAX4466　FFTアルゴリズムの動作準備(0-0 , 0-2)
- SK6812 のテスト動作(0-3)：初期化→色情報を設定(白)→ 点灯
``` c:L46~60
void setup() {
  // 1:Setting
  Serial.begin(115200);                                                     // Begin ing baurate for serial monitor
  pinMode(MIC, INPUT);                                                      // Set MAX4466 Pin 
  allpixels_begin();                                                        // Userfunction 1
  allpixels_clear();                                                        // Userfunction 2
  // 2:Test Light
  for (int i = 0; i < NUMPIXELS; i++) {
    allpixels_clear();                                                      
    allpixels_setbright();
    allpixels_show();
    delay(100);
  }
}
```
## 3-3.loop部
loop部では、フローチャート通りの順序で動作します。
詳細な処理は3-4で記述しています。
```c:L61~71
void loop() {
  sample(fftsamples);                               //01:Sample audio data           [Userfunction01]
  DCRemoval2(vReal, fftsamples);                    //02:Remove DC from samplingdata.[Userfunction02]
  //Serial.println("Sampling Data");
  //drawChart_Smp(fftsamples);
  FFT.Windowing(FFT_WIN_TYP_HAMMING, FFT_FORWARD);  //03:Set FFTwindow
  FFT.Compute(FFT_FORWARD);                         //04:FFT
  FFT.ComplexToMagnitude();                         //05:Translate Comp to Real
  calc_power(fftsamples / 2);                       //06:Calculate spectrum power    [Userfunction03]
  lightup();                                        //07:Lightup 6 herbariums
}
```
## 3-3.ユーザ関数部
ユーザ関数は全てで9つです。
```c:L73~255
/*
 * Userfunction01"allpixels_begin"
 * overview    : begin 6 RGB LEDs
 * returnvalue : none
 * argument    : none
 * Made by Kabosuxu
 */
void allpixels_begin() {
  pixels_LOW.begin();
  pixels_MID1.begin();
  pixels_MID2.begin();
  pixels_MID3.begin();
  pixels_HIGH1.begin();
  pixels_HIGH2.begin();
}

/*
 * Userfunction02"llpixels_setbright"
 * overview    : set the default bright(123) to 6 RGB LEDs.
 * returnvalue : none
 * argument    : none
 * Made by Kabosuxu
 */
void allpixels_setbright() {
  pixels_LOW.setBrightness(123);
  pixels_MID1.setBrightness(123);
  pixels_MID2.setBrightness(123);
  pixels_MID3.setBrightness(123);
  pixels_HIGH1.setBrightness(123);
  pixels_HIGH2.setBrightness(123);
}

/*
 * Userfunction03"allpixels_clear"
 * overview    : clear the color information of 6 RGB LEDs.
 * returnvalue : none
 * argument    : none
 * Made by Kabosuxu
 */
void allpixels_clear() {
  pixels_LOW.clear();
  pixels_MID1.clear();
  pixels_MID2.clear();
  pixels_MID3.clear();
  pixels_HIGH1.clear();
  pixels_HIGH2.clear();
}

/*
 * Userfunction04"allpixels_setcolor"
 * overview    : set the color information of 6 RGB LEDs.
 * returnvalue : none
 * argument    : none
 * Made by Kabosuxu
 */
void allpixels_setcolor() {
  pixels_LOW.setPixelColor(0, pixels_LOW.Color(color[0][0], color[0][1], color[0][2]));
  pixels_MID1.setPixelColor(0, pixels_MID1.Color(color[1][0], color[1][1], color[1][2]));
  pixels_MID2.setPixelColor(0, pixels_MID2.Color(color[2][0], color[2][1], color[2][2]));
  pixels_MID3.setPixelColor(0, pixels_MID3.Color(color[3][0], color[3][1], color[3][2]));
  pixels_HIGH1.setPixelColor(0, pixels_HIGH1.Color(color[4][0], color[4][1], color[4][2]));
  pixels_HIGH2.setPixelColor(0, pixels_HIGH2.Color(color[5][0], color[5][1], color[5][2]));
}

/*
 * Userfunction05"allpixels_show"
 * overview    : show 6 RGB LEDs.
 * returnvalue : none
 * argument    : none
 * Made by Kabosuxu
 */
void allpixels_show() {
  pixels_LOW.show();
  pixels_MID1.show();
  pixels_MID2.show();
  pixels_MID3.show();
  pixels_HIGH1.show();
  pixels_HIGH2.show();
}

/*
 * Userfunction06 "sample" 
 * overview    : Sample the audio data.
 * returnvalue : none
 * argument    : (int)nsamples samplingdata length 
 * Made by Made by Takehiko Shimojima.
 * https://gist.github.com/TakehikoShimojima/13782a144548d1d77fa5e2ff1bc57411#file-sound_fft-ino
 */
void sample(int nsamples) {
  for (int i = 0; i < nsamples; i++) {
    unsigned int sampling_period_us = round(1000000 * (1.0 / SAMPLING_FREQUENCY));
    unsigned long t = micros();
    //vReal[i] = analogRead(MIC)/4095.0 *3.6 + 0.1132;
    vReal[i] = analogRead(MIC);
    vImag[i] = 0;
    while ((micros() - t) < sampling_period_us) ;
  }
}
/*
 * Userfunction07 "DCRemoval2" 
 * overview    : Removal the DC from sampling data.
 * returnvalue : none
 * argument    : (double)*vData
 *               (uint16_t)samples
 * Made by Takehiko Shimojima.
 * https://gist.github.com/TakehikoShimojima/13782a144548d1d77fa5e2ff1bc57411#file-sound_fft-ino
 */
void DCRemoval2(double *vData, uint16_t samples) {
  double mean = 0;
  // calculate mean
  for (uint16_t i = 1; i < samples; i++) {
    mean += vData[i];
  }
  mean /= samples;
  for (uint16_t i = 1; i < samples; i++) {
        vData[i] -= mean;
  }
}

/*
 * Userfunction08 "calc_power" 
 * overview    : Calculate the spectrum power.
 * returnvalue : none
 * argument    : (int)nsamples samplingdata length 
 * Made by Kabosuxu
 */
void calc_power(int nsamples) {
    double sum[6] ={0.0, 0.0 , 0.0 , 0.0 , 0.0 , 0.0}; 
    int avg_div[6] ={21, 31 , 51 , 102 , 103 , 102};
    //Serial.println("sound level !!");
    for (int band = 0; band < nsamples; band++) {
      int df = ( band * SAMPLING_FREQUENCY ) / fftsamples;
      double d = vReal[band]/nsamples;
      if(d > 30){
        if(band <= 20){
          sum[0] += d;
        }else if((21 <= band) && (band <= 51)){
          sum[1] += d;
        }else if((52 <= band) && (band <= 102)){
          sum[2] += d;
        }else if((103 <= band) && (band <= 204)){
          sum[3] += d;
        }else if((205 <= band) && (band <= 307)){
          sum[4] += d;
        }else if((308 <= band) && (band <= 409)){
          sum[5] += d;
        }
      }
    }
    for(int i = 0; i < 6; i++){
      //Serial.print("Freq[");
      Serial.print(i);
      //Serial.print(":");
      avg[i] = sum[i] / avg_div[i];
      Serial.println(avg[i]);
      //Serial.print(",");
    }
    //Serial.println("End");
}


/*
 * Userfunction09"lightup"
 * overview    : light up 6 RGB LEDs according to the frequency.
 * returnvalue : none
 * argument    : none
 * Made by Kabosuxu
 */
void lightup(){
  allpixels_clear();                                            // Userfunction 3
  int adj = 10;
  for (int i = 0; i < NUMPIXELS; i++) {
    allpixels_clear();                                          // Userfunction 3
    allpixels_setcolor();                                       // Userfunction 4
    pixels_LOW.setBrightness(avg[0]*adj);
    pixels_MID1.setBrightness(avg[1]*adj);
    pixels_MID2.setBrightness(avg[2]*adj);
    pixels_MID3.setBrightness(avg[3]*adj);
    pixels_HIGH1.setBrightness(avg[4]*adj);
    pixels_HIGH2.setBrightness(avg[5]*adj);
    allpixels_show();                                           // Userfunction 5
  }
}
```

# 4.課題
本システムを作った目的は、[こちら](https://twitter.com/pvw_i9/status/1523282996920348672)のファンアートへの適用でした。
GW期間中の完成を第一としたため、メモリサイズを意識したアルゴリズム開発を行っておりません。
サンプリング周波数やNを増やすと、当然ながらメモリが足りなくなってしまいます。
またライブラリ化においても、グローバル変数を多用している箇所を改善する必要があります。

# 5.製作を終えて
FFTを実装できるESP32は優秀なマイコンであると感じました。
インターネットにも接続できることから、機能拡張にとても幅が広がり面白いことができそうです！
絶賛アイデア募集中です。
※著作権とスケッチサイズには気をつけたいですね。

読んでくださりありがとうございました！