M5StickCにトーン信号入力してみる


ESP32への入力手段を増やそうと、トーン信号で入力してみました。
トーン信号とは、電話をする際に相手の電話番号をダイアルするための番号のボタンを押したときの「ピー、ポー、パー」という音です。DTMF(Dual-Tone Multi-Freqency)というらしいです。

#DTMFとは

DTMF(Dual-Tone Multi-Freqency)は、2つの周波数の音声を足し算したものになっています。2つの音が重なって和音っぽく聞こえるのはそのためです。
縦軸となる1つ目の周波数と、横軸となる2つ目の周波数で、以下のような表の通りに1つの文字が決まります。

||1209Hz|1336Hz|1477Hz|1633Hz|
|---|---|---|---|---|---|
|697Hz|1|2|3|A|
|770Hz|4|5|6|B|
|852Hz|7|8|9|C|
|941Hz|*|0|#|D|

あとは、上記の音が50msc以上続けば番号ボタン押下が有効とみなし、30msec以上無音が続けばボタンリリースされたとみなすそうです。一連の押下からリリースまで120msec以上とのことです。厳密には。

(参考) Wiki：DTMF
　https://ja.wikipedia.org/wiki/DTMF

#Arduinoでの実現方法

周波数での処理なので、フーリエ変換が必要となります。
そこで、以下のarduinoFFTを使いました。ArduinoIDEにて、ライブラリインストールしておいてください。

kosme/arduinoFFT
　https://github.com/kosme/arduinoFFT

こんな感じです。

```
    FFT.Windowing(vReal, num_samples, FFT_WIN_TYP_HAMMING, FFT_FORWARD);
    FFT.Compute(vReal, vImag, num_samples, FFT_FORWARD);
    FFT.ComplexToMagnitude(vReal, vImag, num_samples);
```

vRealに入力信号の値を入れると、vRealの前半半分に周波数の値が入ってきます。


##音声の録音

以下を参考にさせていただきました。

* [M5StickCのマイクを使ってみる その3 録音再生](https://lang-ship.com/blog/work/m5stickc-mic-3/)
* [M5StickCとSpeaker HatでAI Chatと会話](https://qiita.com/poruruba/items/c618130aff69717bbcda)

##Arduinoソースコード

以下の感じです。

```c:FFT_dtmf.ino
#include <M5StickC.h>
#include <driver/i2s.h>
#include <arduinoFFT.h>

#define PIN_CLK       (0)           // I2S Clock PIN
#define PIN_DATA      (34)          // I2S Data PIN
#define SAMPLING_RATE (8192)       // サンプリングレート(44100, 22050, 16384, more...)
#define BUFFER_LEN    (1024)        // バッファサイズ

#define PEAK_THRESHOLD  400
#define CHARS_LEN   64

#define BLANK_LINE  "                        "

enum RecState {
  REC_INIT,
  REC_RUNNING,
  REC_FINISHED
};

int16_t soundBuffer[BUFFER_LEN / 2];    // DMA転送バッファ

uint32_t g_duration;
uint16_t g_max_chars;

char g_recv_chars[CHARS_LEN];
uint16_t g_recv_index;
char g_recv_1prev = '\0';
char g_recv_2prev = '\0';

const char g_chars[] = { '1', '2', '3', 'A', '4', '5', '6', 'B', '7', '8', '9', 'C', '*', '0', '#', 'D' };
const uint16_t rates_y[] = {
  697, 770, 852, 941
};
const uint16_t rates_x[] = {
  1209, 1336, 1477, 1633
};

enum RecState recState = REC_INIT;               // 録音状態

arduinoFFT FFT = arduinoFFT();

double vReal[BUFFER_LEN / 2];
double vImag[BUFFER_LEN / 2];

// 録音をする
void i2sRecord(uint32_t duration, uint16_t max_chars) {
  g_duration = duration;
  g_max_chars = max_chars;
  
  // 録音用設定
  i2s_config_t i2s_config = {
    .mode                 = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_PDM),
    .sample_rate          = SAMPLING_RATE,
    .bits_per_sample      = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format       = I2S_CHANNEL_FMT_ALL_RIGHT,
    .communication_format = I2S_COMM_FORMAT_I2S,
    .intr_alloc_flags     = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count        = 2,
    .dma_buf_len          = BUFFER_LEN,
    .use_apll             = false,
    .tx_desc_auto_clear   = true,
    .fixed_mclk           = 0,
  };

  // PIN設定
  i2s_pin_config_t pin_config;
  pin_config.bck_io_num   = I2S_PIN_NO_CHANGE;
  pin_config.ws_io_num    = PIN_CLK;
  pin_config.data_out_num = I2S_PIN_NO_CHANGE;
  pin_config.data_in_num  = PIN_DATA;

  // 録音設定実施
  i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
  i2s_set_pin(I2S_NUM_0, &pin_config);
  i2s_set_clk(I2S_NUM_0, SAMPLING_RATE, I2S_BITS_PER_SAMPLE_16BIT, I2S_CHANNEL_MONO);

  // 録音開始
  xTaskCreatePinnedToCore(i2sRecordTask, "i2sRecordTask", 2048, NULL, 1, NULL, 1);
}

// 録音用タスク
void i2sRecordTask(void* arg){
  // 初期化
  g_recv_index = 0;
  memset(g_recv_chars, '\0', sizeof(g_recv_chars));
  g_recv_1prev = '\0';
  g_recv_2prev = '\0';

  // LED On
  uint8_t toggle = LOW;
  digitalWrite(GPIO_NUM_10, toggle );

  // 録音処理
  recState = REC_RUNNING;
  uint32_t start_tim = millis();
  uint32_t end_tim = start_tim;
  while (recState == REC_RUNNING && (end_tim - start_tim) <= g_duration ) { // 指定時間経過するまで繰り返し
    // I2Sからデータ取得
    size_t transBytes;
    esp_err_t err = i2s_read(I2S_NUM_0, (char*)soundBuffer, BUFFER_LEN, &transBytes, (200 / portTICK_RATE_MS));
    if( err != ESP_OK ){
      Serial.println("i2s_read error");
      end_tim = millis();
      continue;
    }
    // LEDをトグル
    toggle = (toggle == LOW) ? HIGH : LOW;
    digitalWrite(GPIO_NUM_10, toggle );

    uint16_t num_samples = transBytes / 2;

    // int16_tからdouble型に変換
    for (int i = 0 ; i < num_samples ; i++ ) {
      vReal[i] = (double)soundBuffer[i];
      vImag[i] = 0.0;
    }

    // フーリエ変換
    FFT.Windowing(vReal, num_samples, FFT_WIN_TYP_HAMMING, FFT_FORWARD);
    FFT.Compute(vReal, vImag, num_samples, FFT_FORWARD);
    FFT.ComplexToMagnitude(vReal, vImag, num_samples);
    double peak = FFT.MajorPeak(vReal, num_samples, SAMPLING_RATE);

    // 値が大きい周波数の抽出
    int index_y = ( rates_y[0] * num_samples ) / SAMPLING_RATE;
    double sum_y = vReal[index_y];
    double max_y = vReal[index_y];
    uint8_t freq_y = 0;
    for( int i = 1 ; i < 4 ; i++ ){
      int index = ( rates_y[i] * num_samples ) / SAMPLING_RATE;
      if( vReal[index] > max_y ){
        freq_y = i;
        max_y = vReal[index];
      }
      sum_y += vReal[index];
    }
    sum_y -= max_y;

    int index_x = ( rates_x[0] * num_samples ) / SAMPLING_RATE;
    double sum_x = vReal[index_x];
    double max_x = vReal[index_x];
    uint8_t freq_x = 0;
    for( int i = 1 ; i < 4 ; i++ ){
      int index = ( rates_x[i] * num_samples ) / SAMPLING_RATE;
      if( vReal[index] > max_x ){
        freq_x = i;
        max_x = vReal[index];
      }
      sum_x += vReal[index];
    }
    sum_x -= max_x;

    char c; // 検出した文字
    if( peak >= PEAK_THRESHOLD && max_y >= sum_y && max_x >= sum_x ){
      int index_y = ( rates_y[freq_y] * num_samples ) / SAMPLING_RATE;
      int index_x = ( rates_x[freq_x] * num_samples ) / SAMPLING_RATE;
      c = g_chars[4 * freq_y + freq_x];
      Serial.printf("p=%lf c=%c fy:v=%d:%lf fx:v=%d:%lf\n", peak, c, rates_y[freq_y], vReal[index_y], rates_x[freq_x], vReal[index_x]);
    }else{
      c = '\0';
      Serial.println("");
    }
    if( g_recv_1prev == c && g_recv_2prev != c ){
      // 同じ文字が連続した場合にその文字を確定
      if( c != '\0'){
        g_recv_chars[g_recv_index++] = c;
        // 指定文字長を超えた場合終了
        if( g_recv_index > g_max_chars )
          break;
      }
    }
    g_recv_2prev = g_recv_1prev;
    g_recv_1prev = c;
    
    vTaskDelay(1 / portTICK_RATE_MS);
    end_tim = millis();
  }

  i2s_driver_uninstall(I2S_NUM_0);

  // LED Off
  digitalWrite(GPIO_NUM_10, HIGH);
  recState = REC_FINISHED;

  // タスク削除
  vTaskDelete(NULL);
}

void setup() {
  M5.begin();
  
  M5.Lcd.setRotation(3);
  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setTextColor(WHITE, BLACK);
  M5.Lcd.setTextSize(1);
  M5.Lcd.println("[M5StickC]");

  Serial.begin(115200);
  Serial.println("setup");
  
  pinMode(GPIO_NUM_10, OUTPUT);
  digitalWrite(GPIO_NUM_10, HIGH);

  M5.Lcd.println("DTMF Recorder");
  Serial.println("DTMF Recorder");
  M5.Lcd.println("BtnA Record");
}

void loop() {
  M5.update();

  if ( M5.BtnA.wasPressed() ) {
    // 録音スタート
    M5.Lcd.setCursor(0, 36);
    M5.Lcd.println(BLANK_LINE);
    M5.Lcd.setCursor(0, 36);
    M5.Lcd.println("REC...");
    Serial.println("Record Start");
    delay(500);
    i2sRecord(10000, 32);
    delay(10);
  }else if( recState == REC_FINISHED ){
    // 録音終了時
    Serial.printf("recv=%s\n", g_recv_chars);
    M5.Lcd.setCursor(0, 36);
    M5.Lcd.println(BLANK_LINE);
    M5.Lcd.setCursor(0, 36);
    M5.Lcd.println(g_recv_chars);
    recState = REC_INIT;
  }
}
```

##ちょっと解説

サンプリングレート8KHz、バッファサイズ1024バイト、1サンプル当たり2バイトなので、1024/8192/2=0.0625、約63msecで、バッファが埋まります。フーリエ変換でトーン信号を検出するタイミングはそれぐらいで良いかなあと。。。

あとは、縦軸と横軸それぞれ１つずつ強い周波数がでているはずです。
やってみると、(ある程度対策はしているのですが)ゴミも拾うようで、2つ連続しないと入力トーン信号として確定しないようにしました。

#使い方

今回は、M5StickCを使っています。
マイクが標準で付いており、ボタンやLCDといった便利な周辺デバイスもついているためです。

使い方は、M5StickC表面のボタンを押すだけです。
そうすると、LEDが点滅し、10秒間トーン信号を待ち受けます。最大32文字にしています。
で、10秒立つと、LCDにその文字が表示されます。

ちなみに、トーン信号の発生は、なんでもよいのですが、以下のAndroidアプリを使いました。

周波数ジェネレータ
　https://play.google.com/store/apps/details?id=com.boedec.hoel.frequencygenerator&hl=ja

(おことわり)
なぜか、ボタン押下一発目はi2s_read関数が返ってこず、処理が止まってしまいます。LEDが点滅しないのでわかると思います。その場合はもう一度ボタンを押してください。
どこかバグが潜んでいるのかなあ。。。。

以上