方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

ESP32 + I2Sマイクロフォン(INMP441)で音声認識を実装

esp32-i2s-microphone
この記事は約15分で読めます。

 

はじめに

様々な方言を話すおしゃべり猫型ロボット『ミーア』を開発中。

今までは一方向の音声出力しか提供していなかったが、いよいよ双方向の開発に取り掛かりたいと思う。

 すでに、音声合成 → MQTT → ESP32再生の下り方向の音声処理は実装済みなので、双方向対話のためには、音声認識→STTでテキストに変換の部分を実装すれば良い。

 

INMP441(I2Sマイク)とESP32を接続

今回は音声認識マイクとしてINMP441のI2Sマイクロフォンを使用して、ESP32に接続する。

音声認識マイクとしてはPDMマイクとI2Sマイクの主に2つがあるが、違いに関してはこちらの記事で記載した。

 ESP32は柔軟なGPIOマトリクスを採用しており、I2Sの信号線(SCK, WS, SD)を任意のGPIOに割り当て可能なので、別に下記のGPIOピンに接続しなければならないわけではない

ただ、SDはデータ入力に該当するので、ESP32の入力対応ピン(GPIO34〜39)のどれかを割り当てた方がよい。入力専用ピン(34〜39)は、入力専用として入力インピーダンスが高いので、信号源(マイク)から見て、電流が流れにくいため、信号が保たれやすい。ただ、I2Sマイクのようにデジタル信号を扱う場合は、マイクとESP32を短い距離で直接配線している限り、出力可能ピンでも実用上ほとんど差はない。

  • VDD → 3.3V:動作電圧が3.3V専用なので5Vに繋いではダメ
  • GND → GND
  • SD(シリアルデータ) → GPIO34
  • WS(ワードセレクト) → GPIO25
  • SCK(シリアルクロック) → GPIO33
  • L/R → GND(左チャンネル用):入力をステレオにする場合にはI2Sマイクを2つ用意して、それぞれGND・VDDに接続して左右チャネルとする必要がある。ただ、ステレオが必要な時は、空間感や臨場感が必要なときのみであり、今回は音声認識と対話で「何を言ったか」が重要なので左右の区別は不要。つまりモノラルで良い

 今回は検証が目的なので、手元にあった開発用ボードにジャンパー線でINMP441を下記のように接続した。

 

マイク処理クラスの実装

microphone.cppで、I2Sマイクからの入力を処理するクラスを実装する。

今回はI2Sマイクから信号処理できているかどうかのみをクイックに検証したいので、「I2Sマイクからの信号受信 → 音量レベルやサンプル統計をリアルタイム表示」までを行う。

サプリングレート(SAMPLE_RATE):16kHz

  • 1秒間に何回 音の波を測って、時間の変化(=周波数=高さ)を記録するか。単位:Hz(1秒間のサンプル数)。高い音ほど波が速く振動するので、サンプリング回数が少ないと波形がよくわからなく、結果的に高音をうまく記録できなくなる

人間の可聴帯域(〜20kHz)に対して

  • 16kHz:音声認識・通話品質に最適(Google STTもこの周波数で十分)
  • 8kHz:電話音質(やや劣るが超低帯域)→ラジオや電話の音っぽく、少しザラザラ聞こえる
  • 44.1kHz or 48kHz:音楽品質(オーバースペック)

→16kHzに設定

ビット深度(bits_per_sample):16bit

  • 1回の音の大きさ(振幅)をどれくらい細かく数字で表すか。1サンプルが何bitで表現されるか(精度)。8bitの場合は256段階(0〜255)、16bitの場合は65,536段階(-32,768〜+32,767)になる
  • 一般的には 16bit or 24bit(最大32bitで処理)
  • I2Sではよく32bit幅で受信→上位16bit or 24bitを使用という形式

音声対話では16bitで十分

バッファサイズ(読み取り単位):512

  • BUFFER_SIZEsamples[]のサイズ(=1回で読み取るサンプル数)。小さすぎると、毎回少しずつサンプルが来るので処理が大変になる。一方で大きすぎると、音を溜めすぎて、話しかけたのに遅れて反応(つまり遅延)になる

→中くらい(512)に設定

DMAバッファ設定(dma_buf_count / dma_buf_len)

  • ESP32のI2SはDMA(Direct Memory Access)で音声バッファを効率的に扱う。ESP32が音を取りに行かなくても、自動で音を受け取ってメモリにためてくれる仕組み。
  • マイク → DMAバッファ → ESP32がまとめて読むという流れ。

DMAバッファが全部満タンになって(dma_buf_count * dma_buf_len)、ESP32がまだ読み取ってなかったら、 新しく来た音声データは“上書き”されて消えてしまう(=データ欠落)。

[INMP441]
   │
   ▼
+--------+   +--------+   +--------+   +--------+
| バケツ1 |→ | バケツ2 |→ | バケツ3 |→ ... (dma_buf_count)
+--------+   +--------+   +--------+
     ↑
     └── ESP32が「空いたバケツ」を順番に取りに行く

だったら、dma_buf_countもdma_buf_lenもできるだけ大きくすれば安全ではないかと思うが、データはRAMにたまるのであり、ESP32のRAMは520KBと少ないので、あまりメモリを無駄に使いたくない。なのでバランスが大切になる。 

520KBのRAMのうち、アプリケーションで自由に使えるのはせいぜい300〜350KB 程度(残りはWi-Fiやスタック用)。音声データのバッファは 連続したメモリ を使うため、大きくしすぎるとすぐ枯渇する。

使用メモリ = dma_buf_count × dma_buf_len × (サンプルのサイズ)

Ruby
dma_buf_count = 6
dma_buf_len = 512
1サンプル = 4バイト(32bit)
6 × 512 × 4 = 12,288バイト(約12KB)

# 16個 × 2048にすると
16 × 2048 × 4 = 131,072バイト(128KB)!
→ 危険! 他の処理とぶつかるかも💥

 というわけで、dma_buf_len = 512, dma_buf_count = 6〜8 あたりが安定

C++
#include <Arduino.h>
#include <driver/i2s.h>

// I2S設定用の定数
#define I2S_MIC_SD_PIN 34    // シリアルデータ
#define I2S_MIC_WS_PIN 25    // ワードセレクト (LRクロック)
#define I2S_MIC_SCK_PIN 33   // シリアルクロック
#define I2S_PORT I2S_NUM_0
#define SAMPLE_RATE 16000 
#define SAMPLE_BITS 32
#define BUFFER_SIZE 512

// テスト用のバッファ
int32_t samples[BUFFER_SIZE];

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("I2Sマイクロフォンテスト開始");

  // I2S設定
  i2s_config_t i2s_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = (i2s_bits_per_sample_t)SAMPLE_BITS,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,  // INMP441は左チャンネルを使用
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 6,
    .dma_buf_len = BUFFER_SIZE,
    .use_apll = false,
    .tx_desc_auto_clear = false,
    .fixed_mclk = 0
  };

  i2s_pin_config_t pin_config = {
    .bck_io_num = I2S_MIC_SCK_PIN,
    .ws_io_num = I2S_MIC_WS_PIN,
    .data_out_num = I2S_PIN_NO_CHANGE,
    .data_in_num = I2S_MIC_SD_PIN
  };

  // I2Sドライバのインストール
  esp_err_t result = i2s_driver_install(I2S_PORT, &i2s_config, 0, NULL);
  if (result != ESP_OK) {
    Serial.printf("I2Sドライバのインストールに失敗: %dn", result);
    return;
  }

  // I2Sピン設定
  result = i2s_set_pin(I2S_PORT, &pin_config);
  if (result != ESP_OK) {
    Serial.printf("I2Sピン設定に失敗: %dn", result);
    i2s_driver_uninstall(I2S_PORT);
    return;
  }

  Serial.println("I2S初期化完了");
}

void loop() {
  // バッファをクリア
  memset(samples, 0, sizeof(samples));
  
  // I2Sからデータを読み込む
  size_t bytes_read = 0;
  esp_err_t result = i2s_read(I2S_PORT, samples, sizeof(samples), &bytes_read, portMAX_DELAY);
  
  if (result != ESP_OK) {
    Serial.printf("I2S読み込みエラー: %dn", result);
    delay(1000);
    return;
  }

  // 読み込んだサンプル数
  int samples_read = bytes_read / sizeof(int32_t);
  Serial.printf("読み込んだサンプル数: %dn", samples_read);

  // サンプル値の統計情報
  int32_t min_sample = INT32_MAX;
  int32_t max_sample = INT32_MIN;
  float avg_sample = 0;
  
  for (int i = 0; i < samples_read; i++) {
    // 32ビット値を16ビットにスケーリング(表示用)
    int16_t sample = samples[i] >> 16;
    
    if (sample < min_sample) min_sample = sample;
    if (sample > max_sample) max_sample = sample;
    avg_sample += sample;
    
    // 最初の10サンプルだけ表示(確認用)
    if (i < 10) {
      Serial.printf("サンプル[%d]: %dn", i, sample);
    }
  }
  
  if (samples_read > 0) {
    avg_sample /= samples_read;
    Serial.printf("統計: 最小=%d, 最大=%d, 平均=%.2f, 範囲=%dn", 
                  (int16_t)min_sample, (int16_t)max_sample, avg_sample, (int16_t)(max_sample - min_sample));
  }

  // 音量レベル表示(簡易的なもの)
  int16_t level = max_sample - min_sample;
  Serial.print("音量レベル: ");
  for (int i = 0; i < level / 100; i++) {
    Serial.print("#");
  }
  Serial.println();
  
  delay(500); // 0.5秒待機
} 

 上記コードでは具体的に下記をチェックしている

確認項目目的OKならNGなら
サンプルが変化してるか配線・I2S設定・録音の正常性✅ 配線&設定OK❌ 配線ミス、I2S設定誤り、サンプル全0など
音に反応して # 出るか音量反応の可視化✅ マイクの音声入力を検知できてる❌ 音出しても # 増えない
min/max/avg変化あるか音量・信号強度の確認✅ 振幅の変化が正常に検出されてる❌ 無音でも範囲が広い or ずっと0

 

サンプル値(1回のサンプリングで得られる音の瞬間的な強さ(振幅))

  • 全部「0」や「-1」ばかり → 配線ミス、I2S設定ミス

マイクを無音にすると min/max/avg が低くなるか?

  • 音がないとき、マイクの出力信号は「0付近で微細なノイズだけ」になる
  • 音があるときは、振幅が大きくなって min/max の幅も広がる

期待される数値変化

状態最小最大平均範囲(最大-最小)
無音-5〜+5-3〜+4≈ 05〜10
通常の声-1000+1200≈ ±数百数千
大声-2000+3000≈ ±1000数千〜万超えることも

音を出す前後で「数値が変化」すれば、マイクが正常に音を拾っているという証拠になる。

 

platform.iniで対象ファイルをビルド

platform.ini

C++
[env:microphone_test]
platform = espressif32 @ 6.7.0
board = esp32dev
framework = arduino
board_upload.flash_size = 4MB
build_flags =
    ${env.build_flags}
    -DCORE_DEBUG_LEVEL=5
build_src_filter = -<*> +<microphone_test.cpp>
extra_scripts =
board_build.partitions = default.csv

build_src_filterパラメータは、ビルド対象のソースファイルを指定するための機能。
+<microphone_test.cpp> – このファイルのみを含めるという指定。

デフォルトではPlatformIOはsrcフォルダ内のすべての.cpp、.c、.Sファイルをビルド対象とするが、この設定により、テスト用のコードだけがビルドされる。

動作確認

ビルドと書き込み

ビルドコマンド実行時に環境を指定することで(今回の場合はmicrophone_test)、このファイルだけをビルド対象にできる。

ShellScript
pio run -e microphone_test -t upload

 

シリアルモニタの確認

-e microphone_testオプションで、モニターに使用する環境を指定している

ShellScript
pio run -e microphone_test -t monitor

 シリアルモニタに以下の情報が表示される

  • 読み込んだサンプル数
  • 最初の10サンプルの値
  • 統計情報(最小値、最大値、平均値、範囲)
  • 音量レベルのビジュアル表示(#マークで表示)
ShellScript

サンプル[9]: 0
統計: 最小=-1760, 最大=485, 平均=30.92, 範囲=2245
音量レベル: ######################
読み込んだサンプル数: 512
サンプル[0]: 182
サンプル[1]: 213
サンプル[2]: 0
サンプル[3]: 0
サンプル[4]: 0
サンプル[5]: 0
サンプル[6]: 0
サンプル[7]: 0
サンプル[8]: 0
サンプル[9]: 0
統計: 最小=-1239, 最大=310, 平均=26.49, 範囲=1549
音量レベル: ###############
読み込んだサンプル数: 512
サンプル[0]: -952
サンプル[1]: -1261
サンプル[2]: -1376
サンプル[3]: -1060
サンプル[4]: -772
サンプル[5]: -759
サンプル[6]: -415
サンプル[7]: -147
サンプル[8]: -161
サンプル[9]: -81
統計: 最小=-1376, 最大=260, 平均=35.48, 範囲=1636
音量レベル: ################

最初、音が0としか表示されずに、配線も間違ってないはずなのに。。。と悩まされたが、まさかの、開発ボードの方が正しく機能していなかった。ボードを変えたら動いた。

Teleplotで音声波形を描画

音声波形も描画して視覚的にも確認しようと思う。Arduinoだとシリアルプロットで標準で音声波形を表示できる機能があるが、PlatformIOだと標準では波形表示機能はないため、VS Code拡張機能のTeleplotをインストールする。

インストールすると、VSCodeのステータスバーにTeleplotが表示されるので、クリックして開く。
ESP32を繋いでいるPCのシリアルポートとBaudを115200を選択して、Openを押すと下記のように表示された。

マイクに向かって話しかけているところが振幅が大きく表示されている。

INMP441の動作確認をできたので、次は音声をサーバーに送る部分の実装を行う

タイトルとURLをコピーしました