はじめに
先日、968円で作る。LEDイコライザっぽい表示機という投稿を読み、どうやらArduinoのAD変換で音声信号(入力)が扱えるらしいということを知りました。AVRマイコンのAD変換はそんなに速くないと思っていたのですが、調べてみるとスペアナの作例がいくつか見つかったので、勉強のため自分も挑戦してみました。
目標
個人的な事情にすぎませんが、以下のような目標を設定しました。
- 持て余している手持ちの余剰材料を使ってやりくりする
- 中国の清明節休暇(4/4〜の3連休)中に完成させる
- 電源や外装を設計して、スタンドアロンの観賞用スペアナを仕上げる
完成物
こんな感じになりました。
動画は、撮影や投稿が面倒だったため断念しました。
材料・作り方・ケースの3Dデータ等
Githubのレポジトリに一式置いておきました。
ちなみに回路やピンアサインは@hashitoさんの968円で作る。LEDイコライザっぽい表示機と全く同じです。
プログラム
#define LOG_OUT 0
#define LIN_OUT 1
#define FFT_N 256
#include <FFT.h>
#include <MatrizLed.h>
MatrizLed pantalla;
#define VALUE_TO_BIT(BIT) ((1<<BIT)-1) // convert value such as 4 -> 0b00001111
#define REC_UPPER_LIM 1500
#define REC_LOWER_LIM 150
bool debug_mode = false;
//filter to adjust height. DEFAULT : {1, 1, 1, 1, 1, 1, 1, 1}
//float filter[8] = {1.1, 1.15, 1.25, 1.45, 1.55, 1.75, 2, 3}; //boost at middle and high freq
float filter[8] = {3, 2.5, 1.5, 0.5, 0.6, 1, 2, 3}; //boost at low and high freq
//borders of the frequency axis. DEFAULT : {16,32,48,64,80,96,112,128}
//uint8_t border[8] = {2,3,7,11,16,24,32,69}; //large area
//uint8_t border[8] = {1,2,3,4,8,12,24,32}; //low-freq area
uint8_t border[8] = {1,2,3,4,7,12,16,24}; //narrow area
byte swapbit(byte in_data, int bitsize){
byte buf = 0;
while(bitsize--){
buf = buf << 1;
buf = buf | (in_data & 1);
in_data = in_data >> 1;
}
return buf;
}
void setup(){
Serial.begin(115200);
TIMSK0 = 0; //turn off timer0 for lower jitter
ADCSRA = 0xe5;//set the adc to free running mode
ADMUX = 0x40; // use adc0
DIDR0 = 0x01; // turn off the digital input for adc0
analogReference(DEFAULT); //set aref to external
pantalla.begin(12, 11, 10, 2); // dataPin, clkPin, csPin
pantalla.setIntensity(0, 0);
}
void loop() {
while(1) { //reduces jitter
cli(); //UDRE interrupt slows this way down on arduino1.0
for (int i=0;i<FFT_N*2;i+=2) { //save 256 samples
while(!(ADCSRA & 0x10)); //wait for adc to be ready
ADCSRA = 0xf5; //restart adc
byte m = ADCL; //fetch adc data
byte j = ADCH;
int k = (j << 8) | m; //form into an int
k -= 0x0200; //form into a signed int
k <<= 6; //form into a 16b signed int
fft_input[i] = k; //put real data into even bins
fft_input[i+1] = 0; //put real data into odd bins
}
fft_window(); // window the data for better frequency response
fft_reorder(); // reorder the data before doing the fft
fft_run(); // process the data in the fft
fft_mag_lin(); // take the output of the fft
sei();
for(int i=0;i<8;i++){
uint16_t maxSignal = 0;
for(int j=border[i];j<border[i+1];j++){
if(fft_lin_out[j] > maxSignal){
maxSignal = fft_lin_out[j];
}
}
if(debug_mode){
Serial.print(maxSignal);
Serial.print("->");
}
//filtering
if(maxSignal < REC_LOWER_LIM){
maxSignal = 0;
}else if(maxSignal > REC_UPPER_LIM){
maxSignal = 65535;
}else {
maxSignal = map(maxSignal, REC_LOWER_LIM, REC_UPPER_LIM, 0, 65535);
maxSignal = (uint16_t)(maxSignal * filter[i]);
if(maxSignal > 65535) maxSignal = 65535;
}
//calc height
uint8_t height = (uint8_t)map(maxSignal, 0, 65535, 0, 8);
if(debug_mode){
Serial.print(maxSignal);
Serial.print("->");
Serial.print(height);
Serial.print(" | ");
}
//LED output
byte led_value = VALUE_TO_BIT(height);
pantalla.setRow(0, i, swapbit(led_value, 8));
}
Serial.println("");
}
}
プログラムの説明
setup()
シリアル通信の準備、AD変換のレジスタ設定、MatrixLEDの準備をしています。各ライブラリのサンプルのコピペですが、要点としては、ADCSRAで分周比が32(ADPSが0b101)に設定されており、16MHzクロックのArduinoを使えばサンプリング周波数が最速で38kHzになる計算です。この辺の話は、以下のページが参考になります。
loop()
大きく見ると、以下の流れになっています。
- A0端子から256サンプル集める
- FFTを実行する
- MatrixLEDに結果を表示する
まず最初のAD変換は、Arduinoの関数を使わずAVRマイコンのレジスタを直接参照する形で値を受け取っています。Arduino(Atmega328)のAD変換は分解能が10bitなので、下位8bitがADCL、上位8bitがADCHに入ります。mとjでそれらを受け取り、ビットシフトとORで結合しています。この時点で値は0~1023の16bit整数になっていますので、これを符号有り(つまり中央値が0である)16bit整数にするため、-512(0x0200)して、64倍(6bit右にずら)しています。
fft_inputという配列はライブラリ内で宣言されているグローバル変数です。偶数番目の要素に信号値の実数部を、奇数番目の要素に信号値の虚数部を代入するルール(使い方)になっているため、i番目とi+1番目に値を代入しています。
次のFFT実行部分は、ライブラリのサンプルと全く同じで説明できることもないため説明を割愛します。
最後はMatrixLEDの表示です。このプログラムでは1列ずつ表示するために、8回まわるループに入っています。そしてこのループの中で、その周波数帯のなかの最大値を取り出して、それを8ドットのLEDにうまく割り付けて表示しています。この「うまく割り付ける」のがスペアナを作る上での最大の難点ですので、以下、もう少し詳しく説明します。
8x8ドットで信号強度をどう表示するか
今回のFFTの結果は、横軸(配列の要素番号)が周波数、縦軸(配列の各要素の値)が信号強度になっています。配列の要素数は128(FFT_N/2)で、信号強度は0~65535までの値を取ります。さてこれを、8x8のLEDにどう表示させると良いでしょうか?
まず思いつくのは、128個の要素を16個ずつ8個のグループに分けて、要素値の平均を取り、それを8192で割る方法です。つまり完全に相加的・線形的に縮小する方法ですが、これではうまくいきませんでした。具体的には、全体的に信号強度が低くなり、殆どLEDが光らない結果になりました。
次に、平均を取るのをやめて、最大値を取るようにしました。すると、LEDは光るようになりましたが、低域〜中域はすごく信号強度が強く、広域は全く信号が出ていない、という光り方になりました。
その後、ふと、「普通のスペクトル表示の横軸って対数表示だよな」と思い、低域を表示する際に参照する要素数を16個よりも少なくして、逆に広域の要素数を増やす、という方法を試しました。(プログラムのborder[]
という配列が、それです。)すると少し改善はしたものの、大きく改善はしませんでした。
この辺で、これはそんなに単純な話ではないことに気づきました。というのは、いま得られているFFTの結果は非常に多くの影響を受けているからです。パッと考えただけでも、
- マイクの周波数特性(感度)
- マイクモジュールのプリアンプの周波数特性
- Arduinoの入力端子までの回路の周波数特性
- たまたま拾った256サンプルの信号に含まれる周波数成分
- 256サンプルの信号強度
のような要因により、8x8ドットの表示にふさわしい(自分が想定している)FFT結果を得られるか分かりません。そこで、ここからは自分の環境に限定してチューニングをすることにしました。その結果が、コメントでfilteringと書いた部分です。具体的には、以下のような対応をしています。
- 横軸は、
border[]
の数に応じて分割する。(上述の通り) - 信号強度は、
-
REC_LOWER_LIM
〜REC_UPPER_LIM
の範囲外の値は最小・最大とみなす。 - 範囲内の値の場合、これを0~65535に拡大する。(
map()
関数) - 拡大したのち、周波数帯(横軸)に応じて係数をかける(
filter[]
) - この結果を0~8に正規化して
height
へ代入する。
-
- ハード的に
ちなみに最後のled_value
やswapbit
は、0~8の値を使ってLEDを表示するための処理です。
さいごに
ということで、得られた知見は以下の通りです。
- Arduinoでスペアナを作ることはできる
- ただし8x8マトリクスで表示するには工夫が必要
- AD変換は意外と速い(5kHz程度の信号ならエイリアシング考慮しても扱えそう)
- マイクの入力信号でスペクトルを表示するならハードのチューニングが不可欠
- プリアンプの倍率調整は必須
- マイクの指向性次第で利便性が変わる
- たまに謎のノイズが乗る。ブレッドボードでの実装はダメかも。
反省点と今後の課題は以下の通り。
- たまにフリーズするが原因が分からない。
- 原因は接触不良、電池残量低下、メモリ不足、のいずれか。要デバッグ。
- Arduino Pro Miniを使わなければよかった。手動リセットだとデバッグしにくい…。
- 8x8ではなくLCDに表示してみたい。(Nokia 5110LCDやSSD1306-OLED等)
- マイクではなくLINE IN入力にしたい。
- オシロ買ってちゃんと測定・検証をしたい。
- FFTのアルゴリズム・原理をちゃんと理解したい。(チューンアップの種が見つかりそう)
参考にしたサイトなど
Arduino FFT Spectrum Analyzer with pedalSHIELDMEGA
最初に見つけたArduino自身でFFTしてスペクトル表示している作例。めっちゃスムーズに動作していてすごい。プログラムを見る限り何も特別な工夫はしていない。使っているライブラリが違うけど、大した差ではないと思われる。これを見るとLCD表示に挑戦したいと思ってしまう。
Arduino Spectrum Analyzer on a 10x10 RGB LED-Matrix
色々試行錯誤して疲れていた時に、「他の人はどうしているのだろう?」と調べていたら見つけた。驚くほど自分と同じ実装になっていて安心した(これが正解というわけではないと思うけど)。同じライブラリを使っている。