junichi.minaminoのアイコン画像
junichi.minamino 2025年01月30日作成 (2025年01月31日更新)
製作品 製作品 閲覧数 126
junichi.minamino 2025年01月30日作成 (2025年01月31日更新) 製作品 製作品 閲覧数 126

カメラの前で音感をチェック(スペック不足?により未完成)

カメラの前で音感をチェック(スペック不足?により未完成)

作品の概要

カメラの前で「ドレミファソラシド」のキーの8音を発声して、全て正しい音感が出せればクリア、というゲームの作成を試みました。

目的

Spresenseで複数の負荷のある処理を同時に行い、問題なく動作するか、を確認してみたかったのが目的・動機になります。

  • カメラからのビデオデータの動き検出を逐次確認
  • マイクからのオーディオデータのピーク周波数を取得
  • ディスプレイに、ビデオ画像と、(条件に沿って)図形とテキストを逐次表示

ゲームの流れ

  1. カメラを顔に合わせて、ゲーム開始のスイッチを押します。ディスプレイに、カメラ画像と、8個の異なる色の正方形が表示されます。
  2. カメラに向かって、「ドレミファソラシド」を発声していきます。一致する音程が確認できたら、該当の正方形が非表示になります。
  3. ゲームの途中で、カメラからの動きが確認できなかった場合、ゲームはやり直し(すべての正方形が表示)になります。
  4. 全てのキーを発声して、全ての正方形が非表示になれば、ゲームクリアとなります。

残念ながら。。。

setup()時に、カメラの開始(theCamera.begin())と録音設定(theAudio->initRecorder)を両方行うと、処理が止まってしまってました。どちらか一方をコメントにすると動作したため、カメラとマイクを両方使用することはハードウェアのスペック的に厳しかったのかもしれないです。
カメラとマイクを両方使用して、そこから重い処理はサブコアに渡すなど設計を検討することが今回の目標だったためだけに、残念ながら未到達となりました。

使用する部品

  • Spresense メインボード
  • Spresense 拡張ボード
  • Spresense HDRカメラボード
  • Mic&LCD KIT for Spresense

設計、実装

ピーク周波数を取得する部分は、昨年のコンテストでのプログラムを活用しました。

各音(ドレミファソラシド)の周波数に一致するかの判定は、以下の周波数値に対して、前後3Hzの範囲内ならOKとしました。

音名 Key 周波数(Hz) 計算
C 261.63 220 * 2^(3/12)
C# 220 * 2^(4/12)
D 293.66 220 * 2^(5/12)
D# 220 * 2^(6/12)
E 329.63 220 * 2^(7/12)
ファ F 349.23 220 * 2^(8/12)
F# 220 * 2^(9/12)
G 392.0 220 * 2^(10/12)
G# 220 * 2^(11/12)
A 440.0 440 * 2^(0/12)
A# 440 * 2^(1/12)
B 493.88 440 * 2^(2/12)
C 523.25 440 * 2^(3/12)

また、動きの検出の判定は、取得した画像のピクセルをグレースケール変換したものに対して、直前のデータと比較して行いました。

成果物

ソースコード

前述の通り、作成した下記のプログラムは暫定で作成したため、全体では動作確認ができていないものとなります。

#include <Camera.h> #include "Adafruit_ILI9341.h" #include <Audio.h> #include <FFT.h> #include <float.h> // FLT_MAX, FLT_MIN // ディスプレイ #define TFT_DC 9 #define TFT_CS 10 Adafruit_ILI9341 display = Adafruit_ILI9341(TFT_CS, TFT_DC); // スイッチ #define SWITCH_PIN_04 4 #define SWITCH_PIN_05 5 #define SWITCH_PIN_06 6 #define SWITCH_PIN_07 7 int ARRAY_SWITCH_PIN[4] = { SWITCH_PIN_04, SWITCH_PIN_05, SWITCH_PIN_06, SWITCH_PIN_07 }; //// #define FFT_LEN 1024 // モノラル、1024サンプルでFFTを初期化 FFTClass<AS_CHANNEL_MONO, FFT_LEN> FFT; AudioClass *theAudio = AudioClass::getInstance(); //// // ゲームの場面を管理 int m_iScene = 0; // スイッチを押したとき bool m_isChanged = false; // 一致したピッチをマイクから取得 bool m_arIsMatchPitch[8] = {false, false, false, false, false, false, false, false}; //// // カメラ画像の上のレイヤーに正方形を描画 void setSquareBox() { // 正方形のサイズと配置の計算 int rows = 3; // 行数(縦方向) int cols = 4; // 列数(横方向) int squareSizeX = display.width() / cols; // 横方向の正方形のサイズ int squareSizeY = display.height() / rows; // 縦方向の正方形のサイズ // 色の設定 uint16_t colors[] = { 0xF800, // 赤 (Red) 0xFC00, // オレンジ (Orange) 0xFFE0, // 黄色 (Yellow) 0x9E00, // 黄緑 (Yellow-Green) 0x07E0, // 緑 (Green) 0x07FF, // 水色 (Cyan) 0x001F, // 青 (Blue) 0x8010 // 紫 (Purple) }; // 正方形を描画 for (int i = 0; i < rows - 1; i++) { for (int j = 0; j < cols; j++) { int iIndex = i * cols + j; // フラグを確認 if (m_arIsMatchPitch[iIndex] == false) { uint16_t color = colors[iIndex]; // 正方形の位置とサイズを計算 int xPos = j * squareSizeX; int yPos = i * squareSizeY; // 正方形を描画 display.fillRect(xPos, yPos, squareSizeX, squareSizeY, color); } } } } // モニターに描画(カメラ、正方形、テキスト) void CamCB(CamImage img) { if (img.isAvailable()) { img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565); uint16_t* imgbuf = (uint16_t*)img.getImgBuff(); // グレースケール変換 uint16_t grayscaleImg[320 * 240]; for (int i = 0; i < 320 * 240; i++) { grayscaleImg[i] = rgb565ToGray(imgbuf[i]) << 8 | rgb565ToGray(imgbuf[i]) << 3 | rgb565ToGray(imgbuf[i]) >> 3; } // 動きの検出(重い) bool isDetectMotion = detectMotion(grayscaleImg); // カメラを描画 display.drawRGBBitmap(0, 0, imgbuf, 320, 240); // 開始座標, 画像データ, 横幅、縦幅 //////// if (m_isChanged == true) { if (m_iScene == 0) { // リセット for (int i = 0; i < 8; i++) { m_arIsMatchPitch[i] = false; } } else if (m_iScene == 1) { // ゲーム開始 for (int i = 0; i < 8; i++) { m_arIsMatchPitch[i] = false; } } m_isChanged = false; } // 正方形を描画 setSquareBox(); //////// // テキストを描画 display.setTextSize(4); display.setCursor(35, 180); if (isDetectMotion) { // 動きが検出された場合の処理 display.setTextColor(ILI9341_RED); display.println("MOVED !"); } else { // 動きがない場合 display.setTextColor(ILI9341_WHITE); display.println("no move"); if (m_iScene == 1) { // ゲーム中、動きがない場合、リセット(すべての正方形を描画) for (int i = 0; i < 8; i++) { m_arIsMatchPitch[i] = false; } } } if (m_iScene == 2) { // ゲームクリア display.setTextColor(ILI9341_RED); display.println("CLEAR !"); } } } void setup() { Serial.begin(115200); // タクトスイッチ Pinモード初期化 for (int i = 0; i < 4; i++) { pinMode(ARRAY_SWITCH_PIN[i], INPUT_PULLUP); } //////// // 液晶ディスプレイの開始 display.begin(); // ディスプレイの向きを設定 display.setRotation(3); // カメラの開始 theCamera.begin(); // カメラのストリーミングを開始 theCamera.startStreaming(true, CamCB); //////// // ハミング窓、モノラル、オーバーラップ50% FFT.begin(WindowHamming, 1, (FFT_LEN/4)); // 入力をマイクに設定 Serial.println("Init Audio Recorder"); theAudio->begin(); theAudio->setRecorderMode(AS_SETRECDR_STS_INPUTDEVICE_MIC); // 録音設定:フォーマットはPCM (16ビットRAWデータ)、 // DSPコーデックの場所の指定 (SDカード上のBINディレクトリ)、 // サンプリグレート 48000Hz、モノラル入力 int err = theAudio->initRecorder(AS_CODECTYPE_PCM, "/mnt/sd0/BIN", AS_SAMPLINGRATE_48000 ,AS_CHANNEL_MONO); if (err != AUDIOLIB_ECODE_OK) { Serial.println("Recorder initialize error"); while(1); } // 録音開始 Serial.println("Start Recorder"); theAudio->startRecorder(); } void loop() { float maxValue0 = 0.0; float peakFs0 = 0.0; // FFTの結果からピーク値を取得 procCalcPeakFsWithFFT(&peakFs0, &maxValue0); // 各音(ドレミファソラシド)の周波数に一致するか? checkPeakFs0(peakFs0); bool isAllMatchPitch = true; if (m_iScene == 1) { for (int i = 0; i < 8; i++) { if (m_arIsMatchPitch[i] == false) { isAllMatchPitch = false; break; } } if (isAllMatchPitch == true) { // ゲームクリア m_iScene = 2; } } //////// // スイッチ for (int i = 0; i < 4; i++) { int switchState = digitalRead(ARRAY_SWITCH_PIN[i]); if (switchState == LOW) { // 押したとき if (i == 0 || i == 1) { m_iScene = i; m_isChanged = true; } } } } // FFTの結果からピーク値を取得 void procCalcPeakFsWithFFT(float *peakFs0, float *maxValue0) { static const uint32_t buffering_time = FFT_LEN * 1000 / AS_SAMPLINGRATE_48000; static const uint32_t buffer_size = FFT_LEN * sizeof(int16_t); static char buff[buffer_size]; static float pDst[FFT_LEN]; uint32_t read_size; // マイクから読み込み int ret = theAudio->readFrames(buff, buffer_size, &read_size); if (ret != AUDIOLIB_ECODE_OK && ret != AUDIOLIB_ECODE_INSUFFICIENT_BUFFER_AREA) { Serial.println("Error err = " + String(ret)); theAudio->stopRecorder(); exit(1); } if (read_size < buffer_size) { delay(buffering_time); return; } // FFTを実行 FFT.put((q15_t*)buff, FFT_LEN); // FFT演算結果を取得 FFT.get(pDst, 0); // 過去のFFT演算結果で平滑化 avgFilter(pDst); // 周波数スペクトルの最大値最小値を検出 float fpmax = FLT_MIN; float fpmin = FLT_MAX; for (int i = 0; i < FFT_LEN/8; ++i) { if (pDst[i] < 0.0) pDst[i] = 0.0; if (pDst[i] > fpmax) fpmax = pDst[i]; if (pDst[i] < fpmin) fpmin = pDst[i]; } //////////////////////////////// // 周波数のピーク値を上位4つまで取得 float maxValue[4] = {0.0, 0.0, 0.0, 0.0}; float peakFs[4] = {0.0, 0.0, 0.0, 0.0}; int ret_peak = get_peak_frequency_list(pDst, peakFs, maxValue); //////////////////////////////// *peakFs0 = peakFs[0]; *maxValue0 = maxValue[0]; } // 各音(ドレミファソラシド)の周波数に一致するか? void checkPeakFs0(float peakFs0) { float arFreq[] = { 261.63, 293.66, 329.63, 349.23, 392.0, 440.0, 493.88, 523.25 }; for (int i = 0; i < 8; i++) { // 前後3Hzの範囲内ならOK if (peakFs0 >= arFreq[i] - 3.0 && peakFs0 <= arFreq[i] + 3.0) { m_arIsMatchPitch[i] = true; break; } } }
//////////////// // カメラ //////////////// uint16_t prevImg[320 * 240]; // 前回の画像のデータを保持するための配列 // グレースケール変換関数 uint8_t rgb565ToGray(uint16_t color) { uint8_t r = (color >> 11) & 0x1F; // R (5ビット) uint8_t g = (color >> 5) & 0x3F; // G (6ビット) uint8_t b = color & 0x1F; // B (5ビット) // R、G、Bの各成分を0-255の範囲にスケール r = r * 255 / 31; g = g * 255 / 63; b = b * 255 / 31; // 輝度(Y)を計算(加重平均) return (uint8_t)(0.299 * r + 0.587 * g + 0.114 * b); } // 動きの検出を行う関数 bool detectMotion(uint16_t* currentImg) { int diffCount = 0; const int threshold = 30; // 動きの閾値(変更しやすい) // 画像の各ピクセルを比較して差分を計算 for (int i = 0; i < 320 * 240; i++) { uint8_t prevGray = rgb565ToGray(prevImg[i]); uint8_t currGray = rgb565ToGray(currentImg[i]); int diff = abs(prevGray - currGray); // 差分が閾値を超えた場合、動きあり if (diff > threshold) { diffCount++; } } // 動きがあったとき return diffCount > (320 * 240) / 10; // 全体の10%以上の差分がある場合に動きありと判定 } //////////////// // マイク //////////////// // フィルター処理 void avgFilter(float dst[FFT_LEN]) { static const int avg_filter_num = 8; static float pAvg[avg_filter_num][FFT_LEN/8]; static int g_counter = 0; if (g_counter == avg_filter_num) g_counter = 0; for (int i = 0; i < FFT_LEN/8; ++i) { pAvg[g_counter][i] = dst[i]; float sum = 0; for (int j = 0; j < avg_filter_num; ++j) { sum += pAvg[j][i]; } dst[i] = sum / avg_filter_num; } ++g_counter; } // 周波数のピーク値を上位4つまで取得 int get_peak_frequency_list(float *pData, float *peakFs, float *maxValue) { uint32_t idx; float delta, delta_spr; // 周波数分解能(delta)を算出 const float delta_f = AS_SAMPLINGRATE_48000 / FFT_LEN; const int Max_Key_Num = 4; for (int i = 0; i < Max_Key_Num; i++) { if (i > 0) { pData[idx-1] = 0.0; pData[idx] = 0.0; pData[idx+1] = 0.0; } // 最大値と最大値のインデックスを取得 float maxValueTmp = 0.0; arm_max_f32(pData, FFT_LEN/2, &maxValueTmp, &idx); if (idx < 1) return i; float data_sub = pData[idx-1] - pData[idx+1]; float data_add = pData[idx-1] + pData[idx+1]; // 周波数のピークの近似値を算出 delta = 0.5 * data_sub / (data_add - (2.0 * pData[idx])); peakFs[i] = (idx + delta) * delta_f; // スペクトルの最大値の近似値を算出 delta_spr = 0.125 * data_sub * data_sub / (2.0 * pData[idx] - data_add); maxValue[i] = maxValueTmp + delta_spr; } return Max_Key_Num; }

考察

本来は、複数の負荷のある処理を同時に行う事象を対象に、メインコアだけで動作させた場合と、サブコアに処理を分散させて動作させた場合とで、挙動の比較をするという考察ができればと思っていました。
今回はカメラを使用するのが初めてだったので、最初のうちはカメラのみの実装テストで時間を使ってしまっていたので、カメラとマイクを両方使用する確認が遅くになってしまったことと、カメラとマイクを両方使用すると処理が止まってしまったことの調査に時間を使ってしまい、かつそれの解決に至らなかったことが残念でした。
またの機会があれば、まずは処理が止まってしまった原因を明確にしたいと思います。

参考資料

SPRESENSEではじめるローパワーエッジAI

junichi.minaminoのアイコン画像
音楽系のアプリ(主にスマートフォン向け)を開発しています。
ログインしてコメントを投稿する