【ESP32入門】ESP32とWS2812Bでオーディオレベルメーターを作る【8回目】

2023/02/04

ESP32 FFT MCP3208 WS2812B レベルメーター 電子工作

t f B! P L
レベルメータ作成のまとめはこちら

はじめに

どうもtakaです。
良き父親を目指して日々奮闘中です。ついに体重が減少に転じました。というよりやつれているだけのような・・・
さて、ESP32とWS2812Bでオーディオレベルメーター製作、ついに最終回!
主たる機能はすでにこれまで紹介しておりますので今回はそれらを組み合わせLEDの点灯パターンを決定すれば完成です!

点灯パターンをどうするか?

WS2812BはフルカラーLEDなのでフルカラーを活かしたいですそこで、次のような点灯パターンを実装しました!

・配色:FFT結果の周波数軸を3分割しRGBに割り当てる
・点灯個数:各周波数範囲で最大スペクトルを点灯個数に割り当てる

配色について

低音域:中音域:高音域=0.03:0.2:1で分割します。
具体的には
低音域(R):中音域(G):高音域(B)=0~450:450~3000:3000~15[kHz]です。

点灯個数について

FFT結果のスペクトルはdBmVに対数圧縮し、その結果をそのまま点灯個数に割り当てます。

例えば、0~450[Hz]のスペクトルの最大スペクトルが20[dBmV]であれば、赤色を20個点灯させます。これをRGB同時に重ねることでフルカラーを活かしています。

ウェーバー・フェヒナーの法則について

法則を要約すると人間の五感は全て対数を感じているというものです。聴覚でいうと音量が小さい領域の変化は敏感ですが、大音量領域は大して差を感じません。味覚も痛覚も同じで、刺激が小さいときほど変化を感じやすく、刺激が大きなときは変化を感じにくいという法則です。オーディオのボリュームでは抵抗が非線形カーブ(Aカーブ)の物が使用されますが、これがまさにこの法則から来ているものです。

スペクトルや周波数分割幅を対数圧縮している理由はこれにあります。リニアスケールのまま点灯させると聴覚と視覚の繋がりが悪く音に連動しているようには感じ取れません。

考えた点灯パターンで実際に点灯させてみる

実際に点灯させるとこんな感じになります。

聴覚の低中高音が直観的にRGBに配色できているように見えますか?
また、音量が直観的に点灯個数に割り当てられているように見えますか?

システムの構成

ハード

前回 の投稿をご覧ください。前回使用しなかったVR3は今回使用します。詳しくはプログラムをご覧いただくとして、VR3でLEDの減光速度を調整できるようにしています。
先述しましたがLEDの個数はスペクトルの強さです。ただ、実直に実際の音に連動させてしまうと目が追い付かずとてもせわしないです。そこでVR3の出番です。この機構は音楽ジャンルに合わせて使うこともできます。スローペースな曲では減光速度を遅くし滑らかに移り変わり、アップテンポな曲では減光速度を速めるような使い方ができます。

ソフト

main.c、MCP3208.cpp、MCP3208.hを全て同じ階層に保存しESP32に焼きこんでください。

・main.ino
#include "arduinoFFT.h"
#include "MCP3208.h"
#include <FastLED.h>

//--------------------------------FFT用定義--------------------------------
#define SAMPLING_FREQUENCY 30000.0
#define FFTSMPLES 1024
double vReal[FFTSMPLES];
double vImag[FFTSMPLES];
arduinoFFT FFT = arduinoFFT(vReal, vImag, FFTSMPLES, SAMPLING_FREQUENCY);
//------------------------------------------------------------------------

//------------------------------MCP3208用定義------------------------------
#define MCP3208_VCC 5.0
#define MCP3208_REF_MILLIVOLT 5000.0
#define SPI_CSPIN 5
#define MCP3208_ADCMAX_VAL 4095.0
#define MCP3208_LCH 0
#define MCP3208_LREFCH 1
#define MCP3208_RCH 2
#define MCP3208_RREFCH 3
#define MCP3208_DIMMERCH 4

mtMCP3208 adcObj(MCP3208_VCC, SPI_CSPIN, MCP3208_REF_MILLIVOLT);
//------------------------------------------------------------------------

//----------------------------LED制御用------------------------------------
#define LED_PIN     16
#define NUM_LEDS    60
#define BRIGHTNESS  100
#define LED_TYPE    WS2812B
#define COLOR_ORDER GRB
CRGB leds[NUM_LEDS];
//-------------------------------------------------------------------------

//------------------------------電圧サンプリング-----------------------------
const unsigned int sampling_period_us = 1000000.0 / SAMPLING_FREQUENCY; //サンプリング間隔[us]
void sample() {
	for (int n = 0; n < FFTSMPLES; n++) {
		unsigned long t = micros();
		vReal[n] = (double)adcObj.readAdc(MCP3208_LCH);
		vImag[n] = 0;
		while ((micros() - t) < sampling_period_us) ;
	}
}
//------------------------------------------------------------------------

//--------------------------------FFT実行---------------------------------
void calcFFT() {
	FFT.Windowing(FFT_WIN_TYP_HAMMING, FFT_FORWARD);	// 窓関数
	FFT.Compute(FFT_FORWARD);	// FFT処理(複素数で計算)
	FFT.ComplexToMagnitude();	// 複素数を実数に変換
}
//------------------------------------------------------------------------
//----------------------------その他サポート関数-----------------------------
//DC成分を削除する
void dcOmit() {
	double mean = 0;
	for (int i = 0; i < FFTSMPLES; i++) {
		mean += vReal[i];
	}
	mean /= FFTSMPLES;

	for (int i = 0; i < FFTSMPLES; i++) {
		vReal[i] -= mean;
	}
}

//FFT結果をデシベル変換する。単位は[dBmV]。
void fft2dBmV() {
	for (int n = 0; n < FFTSMPLES / 2; n++) {
		vReal[n] = vReal[n] * 4.0 / FFTSMPLES;	//これでAD変換のスケールに戻る(0~4095)
		vReal[n] = vReal[n] * MCP3208_REF_MILLIVOLT / MCP3208_ADCMAX_VAL;	//これで電圧値[mV]に戻る。
		vReal[n] = 20.0 * log10(vReal[n]);
		vReal[n] = isinf(vReal[n]) ? 0.0 : vReal[n];
	}
}

//FFT結果にフィルタをかける
void filterFFTres() {
	//とりあえず低周波領域は精度が悪いためスペクトルを捨てる
	for (int n = 0; n < 4; n++) {
		vReal[n] = 0.0;
	}
}

//FFT結果をシリアルモニタに表示する。
void dispFFTres() {
	double dFrq = SAMPLING_FREQUENCY / FFTSMPLES;

	for (int n = 0; n < FFTSMPLES / 2; n++) {
		double frq = (double)n * dFrq;
		double spc = vReal[n];

		Serial.printf("%.0f[Hz] : %.3f[dBmV]¥n", frq, spc);
	}
	Serial.println();
}
//********************************点灯パターン1********************************
//今回はRGBをそれぞれ低音/中音/高音の3に割り当て、点灯させるLEDの個数をスペクトル強度にする。
//FFT結果の周波数軸(=要素数)を3分割しそれぞれの領域の最大スペクトル値を取得する
const uint8_t LOW_FRQ_INDEX = 0;
const uint8_t MID_FRQ_INDEX = 1;
const uint8_t HI_FRQ_INDEX = 2;

void getMaxSpec(int *maxSpec) {
	int fftResSmaples = FFTSMPLES / 2;
	int maxBuf[3] = {0};

	for (int n = 0; n < fftResSmaples; n++) {
		//FFT結果のデシベルがそのままLEDの点灯個数になるので負のデシベル(とても小さな電圧)は0にしておく
		vReal[n] = vReal[n] < 0 ? 0.0 : vReal[n];

		//最大値もLEDの個数でカットする
		vReal[n] = vReal[n] > NUM_LEDS ? NUM_LEDS : vReal[n];

		//解析結果の周波数を三等分(RGB)して各帯域の最大値を取得する
		//横軸も対数化する。今回は簡易的に三分割の方法を0.03:0.2:1で分割してみる
		if (n < fftResSmaples * 0.03) {
			maxBuf[LOW_FRQ_INDEX] = maxBuf[LOW_FRQ_INDEX] > vReal[n] ? maxBuf[LOW_FRQ_INDEX] : (int)vReal[n];
			//Serial.printf("maxBuf:%d,vReal:%f = %d¥n",maxBuf[LOW_FRQ_INDEX] ,vReal[n],maxBuf[LOW_FRQ_INDEX] > vReal[n] ? maxBuf[LOW_FRQ_INDEX] : (int)vReal[n]);
		} else if (n < fftResSmaples * 0.2) {
			maxBuf[MID_FRQ_INDEX] = maxBuf[MID_FRQ_INDEX] > vReal[n] ? maxBuf[MID_FRQ_INDEX] : (int)vReal[n];
		} else {
			maxBuf[HI_FRQ_INDEX] = maxBuf[HI_FRQ_INDEX] > vReal[n] ? maxBuf[HI_FRQ_INDEX] : (int)vReal[n];
		}
	}

	*maxSpec=maxBuf[LOW_FRQ_INDEX];
	*(maxSpec+1)=maxBuf[MID_FRQ_INDEX];
	*(maxSpec+2)=maxBuf[HI_FRQ_INDEX];
	
}
//減光速度をボリュームで調整する処理
int decLed = 70;	//減光スピード。この関数を1回呼び出し前回より点灯個数が少なくなる場合は、すぐに消灯せず照度をこの値だけ減光する
						//ジャズのようなスローテンポなら35くらい
						//ポップなら70位か?
void setDimmer(){
	int dimmerLevel=adcObj.readAdc(MCP3208_DIMMERCH);
	decLed = 255.0*dimmerLevel/MCP3208_ADCMAX_VAL;
}

//getMaxSpecでLED点灯個数が決定したので、照度と色の情報に変換していく

void genColorArray(int *maxSpec) {
	static int ledColor[3][NUM_LEDS]={0};
		
	for (int rgbCnt = 0; rgbCnt < 3; rgbCnt++) {
		for (int n = 0; n < NUM_LEDS; n++) {
			if (n <= *(maxSpec+rgbCnt)) {			//nがスペクトル(個数相当)未満なら照度MAX(255)で点灯
				ledColor[rgbCnt][n] = 255;
			} else if (ledColor[rgbCnt][n] - decLed > 0) {
				ledColor[rgbCnt][n] -= decLed;
			} else {
				ledColor[rgbCnt][n] = 0;
			}
		}
	}

	const uint8_t lowFrqIndex=0 , midFrqIndex=1 , hiFrqIndex=2;
	
	//RGB毎に点灯個数を決定したのでRGB情報に変換する
	for (int n = 0; n < NUM_LEDS; n++) {
		leds[n] = CRGB( ledColor[lowFrqIndex][n], ledColor[midFrqIndex][n], ledColor[hiFrqIndex][n]);
	}
}
//------------------------------------------------------------------------
void setup() {
	Serial.begin(115200);
	delay(3000);

	//AD変換の初期化
	adcObj.adcInit();

	//FastLEDの初期化
	FastLED.addLeds<LED_TYPE, LED_PIN, COLOR_ORDER>(leds, NUM_LEDS).setCorrection( TypicalLEDStrip );
	FastLED.setBrightness(BRIGHTNESS);
	FastLED.clear();
}
void loop() {
	sample();		//SAMPLING_FREQUENCYでFFTSMPLES回サンプリング実施
	dcOmit();		//直流成分の除去
	calcFFT();		//FFT実施
	filterFFTres();	//低周波側の端っこ数要素は精度が怪しいので捨てる。
	fft2dBmV();		//FFT結果をdBmVへ変換する
	
	//dispFFTres();	//デバッグ用
	
	//------LED制御------
	int maxSpec[3] = {0};
	setDimmer();		//減光速度を可変抵抗から読み取る
	//Serial.println(decLed);
	getMaxSpec(maxSpec);	//FFT結果(周波数軸)を3分割し各領域の最大スペクトル値を取得する
	//Serial.printf("low:%d,mid:%d,hi:%d¥n",maxSpec[0],maxSpec[1],maxSpec[2]);
	genColorArray(maxSpec);	//最大スペクトルに応じたLED点灯用配列に格納する。
	FastLED.show();
	
	//デバッグ用
	//Serial.println(adcObj.readAdc(MCP3208_LCH));
	
	//delay(1000);
}

    
・MCP3208.cpp
#include <Arduino.h>
#include <SPI.h>
#include "MCP3208.h"

mtMCP3208::mtMCP3208(uint8_t vcc,uint8_t csPinNo,int refMilliVolt){
	this->vcc=vcc;
	this->csPinNo=csPinNo;
	this->refMilliVolt=refMilliVolt;
}

void mtMCP3208::adcInit() const{
	//SPI設定
	SPI.begin();				//SPIを行う為の初期化
	SPI.setBitOrder(MSBFIRST);	//bitオーダー
	SPI.setDataMode(SPI_MODE0);
	SPI.setFrequency(vcc == 5 ? 2000000 : 1000000);	//MCP3208が5Vの時は最速の2MHzで通信できる
	pinMode(csPinNo, OUTPUT) ;
	digitalWrite(csPinNo, HIGH) ;
}

int mtMCP3208::readAdc(uint8_t channel)const{
	int d1 , d2 ;

	// ADCから指定チャンネルのデータを読み出す
	digitalWrite(csPinNo, LOW) ;
	SPI.transfer(0x06 | (channel >> 2) ) ;
	d1 = SPI.transfer( (channel & 0x03) << 6 ) ;
	d2 = SPI.transfer(0x00) ;
	digitalWrite(csPinNo, HIGH) ;
	return ((d1 & 0x0F) << 8 | d2) ;
}

int mtMCP3208::readMilliVolt(uint8_t channel)const{
	int adcRes=readAdc(channel);
	return refMilliVolt*adcRes/4095.0;
}

    
・MCP3208.h
class mtMCP3208{
	private:
		uint8_t csPinNo;
		uint8_t vcc;
		int adcRes;
		int refMilliVolt;
		int offsetMillVolt;
		
	public:
		mtMCP3208(uint8_t,uint8_t,int);
		void adcInit()const;
		int readAdc(uint8_t)const;
		int readMilliVolt(uint8_t)const;
};

    
うまく動作しましたか?
ソースコードの詳細はコード中のコメントを参照ください。
若干カット&トライで作成したこともありスパゲティー気味です。ご了承を・・・

最終回は以上です。
入門編ということでかなりかみ砕き説明した部分もありボリューミーになってしまいましたが最後までご覧いただきありがとうございました!


全投稿のまとめページは こちら

中の人

自分の写真
モノ作りが好きです。GUIアプリの作成からアナログ回路まで手当たり次第です。 アンプの修理を紹介するためにブログを始めました。 (Twitter:@TakaElc)

記事カテゴリ

44 (1) 5M21 (10) A-6 (5) A-717 (2) A-8 (4) A-950 (1) AB級 (8) Arduino (2) A級 (30) C-21 (1) C29 (1) CA-2000 (2) ESP32 (9) EUMIG (13) FFT (2) HOW2 (6) LED (2) LUXMAN (10) M-1000 (13) M-22 (24) MATLAB (1) mcintosh (1) MCP3208 (3) OPアンプ (1) Pioneer (28) QUAD (2) SA-9800 (1) Simulink (1) SONY (1) TA-F555ESX2 (1) WS2812B (4) YAMAHA (12) アンプ (65) スピーカ (3) プリ (1) プリアンプ (2) プリメイン (14) レベルメーター (10) 化石 (1) 山水 (3) 自作アンプ (7) 電子工作 (29) 日記 (1)

QooQ