#タイマ割り込みを使ってPWMの音を鳴らそう
前回、最後に loop()の中で ledcWrite() で力任せに音を鳴らしたものの、CPU負荷によって音程が変わっていた。ただのループなので当たり前なのではあるが。今回は負荷が変動しても音程がずれないように、loop() ではなくESP32のタイマ割り込み機能を使って実装してみたいと思う。
##環境
使ったESP32 ボートは下の写真のもので、DOITの DEVKIT V1かその互換と思われる。
Arduino IDEは 1.8.12 である。前回同様、GPIO32 (IDE上はA4ピン)をPWM 出力として扱うものとする。
PWMとしては11bitにして、サンプリング周波数はほぼ上限の39kHzとした。
まずはタイマ割り込み
タイマ割り込みの勉強から。ESP32 のサンプルコードでも十分なのですが、GPIO32 (A4 pin) でタイマ割り込み機能を使ってLEDチカチカをやってみる。
この例では ESP32 の timer0 を使って 1秒ごとにLEDをON/OFFします。
// LED output on GPIO32 (A4 pin)
#define LEDC_PIN A4
hw_timer_t *timer0 = NULL;
portMUX_TYPE timerMux0 = portMUX_INITIALIZER_UNLOCKED;
volatile uint8_t ledstat = 0;
void IRAM_ATTR onTimer(){
portENTER_CRITICAL_ISR(&timerMux0) ; // enter critical range
ledstat = 1 - ledstat;
digitalWrite(LEDC_PIN, ledstat); // turn the LED on or off
Serial.print("blinked on core ");
Serial.println(xPortGetCoreID());
portEXIT_CRITICAL_ISR(&timerMux0) ; // exit critical range
}
void setup() {
Serial.begin(115200);
pinMode(LEDC_PIN, OUTPUT);
digitalWrite(LEDC_PIN, LOW);
Serial.println("Start timer ");
timer0 = timerBegin(0, 80, true); // timer0, 12.5ns*80 = 1000ns, count-up
timerAttachInterrupt(timer0, &onTimer, true); // edge-triggered
timerAlarmWrite(timer0, 1000000, true); // 1000000*1000ns = 1sec, auto-reload
timerAlarmEnable(timer0); // enable timer0
}
void loop() {
// vTaskDelay(portMAX_DELAY);
vTaskDelay(3000/portTICK_RATE_MS); // instead of delay(3000);
Serial.println("done in loop()");
}
ESP32は tick という時間単位 (1tick = 80MHz = 12.5ns)を用いて、プリスケーラ(この例では80)やカウンタを動かして、カウンタが設定値(1,000,000)を超えた場合(12.5ns x 80 x 1,000,000 = 1秒)に割り込みが発生し、timerAttachInterrupt() で設定した onTimer() 関数に飛んで行きます。
onTimer() 関数は IRAM_ATTR という修飾辞が付いていますが、割り込み処理は速度を求められるので、おそらくflashメモリ上に展開されないようにするためと思われます。
portENTER_CRITICAL_ISR()~portEXIT_CRITICAL_ISR() はmutexを制御します。この例では一つの処理しかないので、なくても困らないと思います。
ESP32はデュアルコアプロセッサですが、xPortGetCoreID() 関数はどちらのコアで動いているか知ることができます。Arduino環境では どうも core #1上で動作するのが標準のようです。
loop() の中で vTaskDelay(portMAX_DELAY) とすると、二度と戻ってくることはないようです。数時間待てば戻ってくるのかもしれないですが。このサンプルコードでは、loop() から定期的に onTimer() に飛んでいることを確認するために、3秒ごとに println() させています。delay()関数の代わりに vTaskDelay() を使ってみました。tick に換算するために portTICK_RATE_MS で割ります。もちろん delay() で実装しても大丈夫です。
では音を鳴らそう
簡単なタイマ割り込みはできるようになったので、実際に音を鳴らしてみます。サンプリング周波数かそれより速い周期で割り込みをかけてPWMに波形の値を設定することで、原理的には音は鳴るはずです。
今回は波形メモリ音源を目指します。いわゆるSCC音源というやつと同じですな。テーブル (wave[]) に1波形の値を保存しておき(この例では WAVE_TBL_LENGTH = 32サンプルを1波形として)、発音周波数x32の周期でテーブルから読み出して ledcWrite() に書く、というのが基本コンセプトです。ただし、サンプリング周波数と発音周波数は非同期関係(というか無関係)にあるので、そのつじつま合わせがちょっと面倒です。ESP32はDSPを内蔵しているので除算もそれなりの時間で処理できるのかもしれませんが、時間の都合で調べ切れていないので、今回は整数演算でやっつけるようにしています。発音周波数の周期長からタイマ割り込みの度にその分を削って、負数になったら波形テーブルをひとつ進め、発音周波数の周期長も1周期分足す、というグダグダな処理になっています。
PWMのサンプリング周波数(LEDC_FREQ)は 39.0KHz としたので解像度は 11bitが上限になります。タイマ割り込み(INT_FREQ)は12.5us (80kHz) にしていますが、25.0us (40kHz > 39kHz)でも大丈夫のはずです。
前回のコードと合体させてみました。GPIO32 (A4) からPWM出力します。 オクターブ4のラ(A4=440Hz) の音を鳴らします。単音の評価なので波形は派手な音色のノコギリ波にしました。三角波と正弦波もコメントとして残してあります。
onTimer() の中で pwm_tone を x8 しているのは、音量を稼ぐため (単音 = 8bit ⇒ 8倍すると 11bit で最大)です。それ以上の意味はありません。
// LED output on GPIO32 (A4 pin)
#define LEDC_PIN A4
#define LEDC_CHANNEL 0
#define LEDC_FREQ 39000
#define LEDC_TIMERBIT 11
// WAVE TABLE LENGTH
#define WAVE_TBL_LENGTH 32
// TIMER_INTERVAL (ns)
#define INT_FREQ 12500
// #define INT_FREQ 250 // WDT error
hw_timer_t *timer0 = NULL;
portMUX_TYPE timerMux0 = portMUX_INITIALIZER_UNLOCKED;
//int tone0 = 18181818 ; // A1 (55.000Hz)
//int tone0 = 9090909 ; // A2 (110.000Hz)
//int tone0 = 4545455 ; // A3 (220.000Hz)
int tone0 = 2272727 ; // A4 (440.000Hz)
//int tone0 = 1911129 ; // C5 (523.251Hz)
//int tone0 = 1516865 ; // E5 (659.255Hz)
//int tone0 = 1136364 ; // A5 (880.000Hz)
//int tone0 = 568182 ; // A6 (1760.000Hz)
//int tone0 = 284091 ; // A7 (3520.000Hz)
//int tone0 = 142045 ; // A8 (7040.000Hz)
volatile uint8_t tbl_index0 = WAVE_TBL_LENGTH -1;
volatile int count_tone0 = 0;
volatile int pwm_tone = 0;
uint8_t wave[WAVE_TBL_LENGTH] ;
void IRAM_ATTR onTimer(){
portENTER_CRITICAL_ISR(&timerMux0) ; // exit critical range
count_tone0 -= INT_FREQ*WAVE_TBL_LENGTH ;
while( count_tone0 <=0 ) {
count_tone0 += tone0 ;
tbl_index0 ++ ;
}
tbl_index0 &= (WAVE_TBL_LENGTH-1) ;
pwm_tone = ((int) wave[tbl_index0])*8 ;
ledcWrite(LEDC_CHANNEL, pwm_tone) ;
portEXIT_CRITICAL_ISR(&timerMux0) ; // exit critical range
}
void setup() {
Serial.begin(115200);
// intialize wave table (Saw-tooth)
for ( int i=0; i<WAVE_TBL_LENGTH; i++ ) {
wave[i] = (uint8_t) i*(256/WAVE_TBL_LENGTH) ;
}
/*
// intialize wave table (Triangle)
for ( int i=0; i<WAVE_TBL_LENGTH/2; i++ ) {
wave[i] = (uint8_t) i*(512/WAVE_TBL_LENGTH) ;
wave[WAVE_TBL_LENGTH/2+i] = (uint8_t) 255-wave[i] ;
}
// intialize wave table (Sine)
for ( int i=0; i<WAVE_TBL_LENGTH; i++ ) {
wave[i] = (uint8_t) 128+((double) 127*sin((float) 2*3.14159265*i/WAVE_TBL_LENGTH)) ;
}
for ( int i=0; i<WAVE_TBL_LENGTH; i++ ) {
Serial.println(wave[i]) ;
}
*/
ledcSetup(LEDC_CHANNEL, LEDC_FREQ, LEDC_TIMERBIT) ;
ledcAttachPin(LEDC_PIN, LEDC_CHANNEL) ;
Serial.println("======= start timer ============");
timer0 = timerBegin(0, INT_FREQ/12.5, true); // timer 0, 12.5ns*1000 = 12.5us, count up
timerAttachInterrupt(timer0, &onTimer, true);
timerAlarmWrite(timer0, 1, true); // 12.5us * 1 = 12.5us
timerAlarmEnable(timer0); // enable
}
void loop() {
vTaskDelay(portMAX_DELAY);
}
ロジアナの波形は下図です。loop()の中はなにもしてませんが、タイマ割り込みによって440Hzで発生できていることがわかります。
周波数を上げていくと音が濁ってきます。波形メモリ音源の宿命ですが、周波数が上がってくると1サンプル出力する間にテーブルが2以上移動するようになります。この時すでに元の波形ではないので音色も変わってしまいます。今回のコードでは発音周波数が2kHz(オクターブ6)くらいまでなら、耐えられるかなという感じでした。
タイマ周期を短くしてみた
タイマ割り込み周期を短くするとどうなるのでしょうか? 上の sample2.ino で、INT_FREQを250 (250ns = 4MHz) にすると、 持続音ではなく、断続音が鳴ります。シリアルモニタには次のメッセージが出てきていることがわかりました。
Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1)
Core 1 register dump:
PC : 0x40089ffa PS : 0x00060034 A0 : 0x8008805e A1 : 0x3ffbe710
A2 : 0x3ffc1190 A3 : 0x00000000 A4 : 0x00000001 A5 : 0x00000001
A6 : 0x00060023 A7 : 0x00000000 A8 : 0x00000001 A9 : 0x3ffb8058
A10 : 0x00000003 A11 : 0x3ffb8058 A12 : 0x00000001 A13 : 0x00000001
A14 : 0x00060023 A15 : 0x00000000 SAR : 0x00000013 EXCCAUSE: 0x00000006
EXCVADDR: 0x00000000 LBEG : 0x400014fd LEND : 0x4000150d LCOUNT : 0xffffffff
Core 1 was running in ISR context:
EPC1 : 0x400d26e2 EPC2 : 0x00000000 EPC3 : 0x00000000 EPC4 : 0x40089ffa
Backtrace: 0x40089ffa:0x3ffbe710 0x4008805b:0x3ffbe730 0x400d10ab:0x3ffbe770 0x40080ea8:0x3ffbe790 0x40080f9d:0x3ffbe7b0 0x400846cd:0x3ffbe7d0 0x400d26df:0x3ffb1fb0 0x400882f5:0x3ffb1fd0
Core 0 register dump:
PC : 0x400e979a PS : 0x00060134 A0 : 0x800d45aa A1 : 0x3ffbbff0
A2 : 0x00000000 A3 : 0x00000001 A4 : 0x00000000 A5 : 0x00000001
A6 : 0x00060320 A7 : 0x00000000 A8 : 0x800d4172 A9 : 0x3ffbbfc0
A10 : 0x00000000 A11 : 0x40084e58 A12 : 0x00060320 A13 : 0x3ffbb9c0
A14 : 0x00000003 A15 : 0x00060c23 SAR : 0x00000000 EXCCAUSE: 0x00000006
EXCVADDR: 0x00000000 LBEG : 0x00000000 LEND : 0x00000000 LCOUNT : 0x00000000
Backtrace: 0x400e979a:0x3ffbbff0 0x400d45a7:0x3ffbc010 0x400897e6:0x3ffbc030 0x400882f5:0x3ffbc050
Rebooting...
ets Jun 8 2016 00:22:57
WDT (watch dog timer) がタイムアウトしたからリセットしたべ、って言ってます。
割り込み周期を短くしたので、割り込み処理中にさらに割り込みがかかって詰まってしまった、ということだと思います。
タイマ割り込み周期 (INT_FREQ) は割り込み処理の重さによっても調整が必要ということですね。場合によってはデュアルコアのもう一方に処理させるなどの対応が必要になるのでしょう。
和音に挑戦
今度は和音を出してみます。サンプルとして4重和音を鳴らしてみました。コードでいうとAM7(ラ・ド#・ミ・ソ#) です。エレクトリックピアノっぽく正弦波の波形としました。
同時発生音数は MAX_NOTES で設定、配列 tonec[n] に発音する音の周期長を入れればよいです。
wave[] の値が 8bitで、PWMの分解能が 11bit なので、9音以上重なると桁あふれが起きます。pwm_toneを右シフトするなど、桁あふれ処理を追加してください。
// LED output on GPIO32 (A4 pin)
#define LEDC_PIN A4
#define LEDC_CHANNEL 0
#define LEDC_FREQ 39000
#define LEDC_TIMERBIT 11
// tone definition
#define TONE_A1 18181818 // A1 (55.000Hz)
#define TONE_A2 9090909 // A2 (110.000Hz)
#define TONE_A3 4545455 // A3 (220.000Hz)
#define TONE_A4 2272727 // A4 (440.000Hz)
//#define TONE_A4_ 2267574 // A4 detuned (441.000Hz)
#define TONE_A4_ 2257336 // A4 detuned (443.000Hz)
#define TONE_C5 1911129 // C5 (523.251Hz)
#define TONE_Db5 1803866 // C#5 (554.365Hz)
#define TONE_E5 1516865 // E5 (659.255Hz)
#define TONE_Ab5 1203936 // G#5 (830.609Hz)
#define TONE_A5 1136364 // A5 (880.000Hz)
#define TONE_A6 568182 // A6 (1760.000Hz)
#define TONE_A7 284091 // A7 (3520.000Hz)
#define TONE_A8 142045 // A8 (7040.000Hz)
// WAVE TABLE LENGTH
#define WAVE_TBL_LENGTH 32
// TIMER INTERVAL (ns)
#define INT_FREQ 25000
// MAX NOTES
#define MAX_NOTES 4
hw_timer_t *timer0 = NULL;
portMUX_TYPE timerMux0 = portMUX_INITIALIZER_UNLOCKED;
volatile int tonec[MAX_NOTES] ;
volatile uint8_t tbl_index[MAX_NOTES] ;
volatile int count_tone[MAX_NOTES] ;
volatile int pwm_tone = 0;
uint8_t wave[WAVE_TBL_LENGTH] ;
void IRAM_ATTR onTimer(){
portENTER_CRITICAL_ISR(&timerMux0) ; // exit critical range
pwm_tone = 0;
for ( int i=0; i<MAX_NOTES; i++ ) {
count_tone[i] -= INT_FREQ*WAVE_TBL_LENGTH ;
while( count_tone[i] <=0 ) {
count_tone[i] += tonec[i] ;
tbl_index[i] ++ ;
}
tbl_index[i] &= (WAVE_TBL_LENGTH-1) ;
pwm_tone += (int) wave[tbl_index[i]] ;
}
// pwm_tone *= 2 ; // boost
ledcWrite(LEDC_CHANNEL, pwm_tone) ;
portEXIT_CRITICAL_ISR(&timerMux0) ; // exit critical range
}
void setup() {
Serial.begin(115200);
int i ;
/*
// intialize wave table (Saw-tooth)
for ( i=0; i<WAVE_TBL_LENGTH; i++ ) {
wave[i] = (uint8_t) i*(256/WAVE_TBL_LENGTH) ;
}
// intialize wave table (Triangle)
for ( i=0; i<WAVE_TBL_LENGTH/2; i++ ) {
wave[i] = (uint8_t) i*(512/WAVE_TBL_LENGTH) ;
wave[WAVE_TBL_LENGTH/2+i] = (uint8_t) 255-wave[i] ;
}
*/
// intialize wave table (Sine)
for ( i=0; i<WAVE_TBL_LENGTH; i++ ) {
wave[i] = (uint8_t) 128+((double) 127*sin((float) 2*3.14159265*i/WAVE_TBL_LENGTH)) ;
}
// print wave value to debug
for ( i=0; i<WAVE_TBL_LENGTH; i++ ) {
Serial.println(wave[i]) ;
}
// initialize counter and preset tones
for (i=0;i<MAX_NOTES;i++) {
tbl_index[i] = WAVE_TBL_LENGTH -1;
count_tone[i] = 0;
}
// AM7 chord
tonec[0] = TONE_A4 ;
tonec[1] = TONE_Db5 ;
tonec[2] = TONE_E5 ;
tonec[3] = TONE_Ab5 ;
// Detuned A4
// tonec[0] = TONE_A4 ;
// tonec[1] = TONE_A4_ ;
ledcSetup(LEDC_CHANNEL, LEDC_FREQ, LEDC_TIMERBIT) ;
ledcAttachPin(LEDC_PIN, LEDC_CHANNEL) ;
Serial.println("======= start timer ============");
timer0 = timerBegin(0, INT_FREQ/12.5, true);
timerAttachInterrupt(timer0, &onTimer, true);
timerAlarmWrite(timer0, 1, true);
timerAlarmEnable(timer0);
}
void loop() {
vTaskDelay(portMAX_DELAY);
}
複雑な波形になっていますが、足し合わされているのがわかりますね。
おまけ
ディチューン (少しずらした周波数で音を重ねることでうねり響かせる) もやってみたのですが、なぜかそれほどきれいにうなりませんでした。なぜかは分かりませんでした。
プログラムに間違いがあったためでした。今のコードは修正済みです。