[STM32F7で作るMIDI音源] その4 MIDIメッセージの受信とリングバッファ

自作MIDI音源「CureSynth」製作記事の一覧はこちら

前回は、自作MIDI音源「CureSynth」のソフトウェア概要について紹介しました。
今回は、STM32F7+HALライブラリを用い、MIDIメッセージを「USART割り込み」で受信する方法と、受信したMIDIメッセージを蓄積するためのリングバッファについて紹介します。

1.MIDI受信回路

本稿で対象とする回路は、以前作成したvs1053b用の回路を転用し、USART3(RX)に直結しています。以下、USART3をMIDI受信用として話を進めます。

STM32F7VITxのUSART3_RXにMIDI受信回路を接続した図

2.USART割り込みによる受信

2.1.ペリフェラルの設定

USART3をMIDI受信用に設定します。STM32ではペリフェラルの設定がややこしいので、STM32CubeMXを使用すると便利だと思います。説明するのにも、設定画面を載せるのが手っ取り早いですね。たまにバグがあるので、自動生成されたソースコードはよく眺める必要はありますが…

USART3のMIDI受信用の設定 1

STM32CubeMXで、USART3をAsynchronous(非同期)に設定します。フロー制御は行わないためオフとします。

 

USART3のMIDI受信用の設定 2

ConfigurationタブのUSART3ボタンを押し、Parameter Settingタブの内容を設定します。通信速度を31.25kbps、8bitパリティなし、ストップビットを1(H)にします。MIDIはLSB firstなので、MSB FirstをDisableにしておきます。

MIDIのハードウェアレベルの仕様については、以下の記事がとても読みやすいので、お勧めします。
MIDI のハードウェアについて(Y-Lab. Electronics, Elekenさん)

余談ですが、STM32のUSARTは、RX/TXの入れ替えや論理の入れ替えなどをハード側でやってくれるので、安心して設計ミスができますね!

 

USART3のMIDI受信用の設定 3

さらに、NVIC Settingsで、USART3割り込みを有効にしておき、割り込みが発生できるようにします。

以上で設定は完了です。

2.2.実装

USART割り込みのサンプルです。

製作記事その3で示したとおり、MIDIメッセージはバイト単位なので、1バイト(8bit)受信するごとに割り込みが発生するようにします。

また、割り込み処理内でリングバッファ(FIFO)に蓄積し、処理の安定化を図ります。関数cureMidiBufferEnqueue()はバッファへ蓄積する関数です。後述します。

/*main.c*/

//グローバル変数
uint8_t midi_recieved_buf;

//1バイト受信するとコールされる割り込み関数
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
	if(huart->Instance == USART3)
	{
		//受信内容をリングバッファに蓄積する
		cureMidiBufferEnqueue(&midi_recieved_buf);

		//次の受信に備える
		HAL_UART_Receive_IT(&huart3, &midi_recieved_buf,1);
	}
}

int main(void)
{
	//(中略)
	
	//MIDIメッセージの受信を開始。1バイト受信すると、割り込みを発生させる。
	HAL_UART_Receive_IT(&huart3, &midi_recieved_buf, 1);
	
	//(中略)
}

3.リングバッファ

3.1.概念

リングバッファ(循環バッファ)について、MIDIメッセージの受信に必要な概念に限定して説明します。汎用的な内容ついては、以下の記事が分かりやすいと思います。
循環バッファ( ++C++; // 未確認飛行 C , 岩永 信之さん)
キュー(お気楽C言語プログラミング超入門, 広井 誠さん)

 

製作記事その3で示したとおり、MIDIメッセージの受信はUSART割り込みで行い、MIDIメッセージの解析はメインループ内で行うため、MIDIメッセージ受信と解析のタイミングが同期するとは限りません。そこで受信したMIDIメッセージをバッファに蓄積しておき、解析するタイミングでバッファからデータを取り出します。そのためには、バッファにデータを蓄積した順に取り出す必要があります。

これを実現するのがリングバッファです。リングバッファは配列構造をリング状に(論理的に)接続したもので、イメージは次の通りです。リング状にすることで、データ領域の先頭・終端を任意の位置にすることができるため、使用済みの領域を再利用できます。エコですね。

uint8_t型、要素数nのリングバッファ

 

バッファの蓄積(Enqueue)/取り出し(Dequeue)のイメージ図を下記に示します。この例では、(a)初期状態に対し、(b)[0x90, 0x3C, 0x64]の順に3バイト蓄積、(c)[0x90]の1バイト取りだし、(d)[0x91, 0x3D, 0x65]の順に3バイト蓄積、と操作しています。

リングバッファの操作:初期状態(a)→3バイト蓄積(b)→1バイト取り出し(c)→3バイト蓄積(d)

ところで、図中のfront, rearは、要素の先頭や終端にアクセスするためのインデックス変数です。図では、(a)~(d)の各処理直後の値を表しており、次のように使います。

  • front==rearのとき、バッファが空だと判断。(→上図(a))
  • frontがrearの一つ前であるとき、バッファが満杯だと判断。
  • 次に蓄積する位置は、frontとする。buffer[front]でアクセス。
  • 次に取り出すべき位置は、rearとする。buffer[rear]でアクセス。

このとき、front, rearの値を次のように操作すれば、バッファが適切に動作します。

  • バッファの蓄積(Enqueue)と同時にfrontを1増加
  • バッファの取り出し(Dequeue)と同時にrearを1増加
  • front, rearが要素数nを超えたら0にリセットし、要素数nで循環させる

3.2.実装

まず、バッファの構造体型を定義します。


/*curebuffer.h*/

//uint8_t用リングバッファ構造体
typedef struct{
	uint16_t idx_front;
	uint16_t idx_rear;
	uint16_t length;//バッファ長
	uint8_t  *buffer;//バッファの先頭アドレス
}RingBufferU8;

一般的に使えるように、バッファを配列としてではなくポインタ(*buffer)として確保しています。後述する初期化処理内で、領域を確保してから使います。
バッファ長はsizeof(buffer)で求まりますが、sizeofによる演算コスト増加を避けるため、初期化時にlengthに代入しておきます。

次に、このRingBufferU8構造体に対して、初期化処理・解放処理・データ蓄積処理・データ取り出し処理の関数を用意します。これらバッファ操作用関数は、curebuffer.h/cにまとめておきます。

/*curebuffer.c*/

//初期化処理
BUFFER_STATUS cureRingBufferU8Init(RingBufferU8 *rbuf, uint16_t buflen)
{
	uint32_t i;

	cureRingBufferU8Free(rbuf);

	rbuf->buffer = (uint8_t *)malloc( buflen * sizeof(uint8_t) );
	
	if(NULL == rbuf->buffer){
		return BUFFER_FAILURE;
	}
	
	for(i=0; i<buflen; i++){
		rbuf->buffer[i] = 0;
	}

	rbuf->length = buflen;

	return BUFFER_SUCCESS;
}

//解放処理
BUFFER_STATUS cureRingBufferU8Free(RingBufferU8 *rbuf)
{
	if(NULL != rbuf->buffer){
		free(rbuf->buffer);
	}

	rbuf->idx_front = rbuf->idx_rear = 0;
	rbuf->length = 0;

	return BUFFER_SUCCESS;
}

//データ蓄積処理
BUFFER_STATUS cureRingBufferU8Enqueue(RingBufferU8 *rbuf, uint8_t *inputc)
{
	if( ((rbuf->idx_front +1)&(rbuf->length -1)) == rbuf->idx_rear ){//バッファが満杯
		return BUFFER_FAILURE;
	}else{
		rbuf->buffer[rbuf->idx_front]=  *inputc;
		rbuf->idx_front++;
		rbuf->idx_front &= (rbuf->length -1);
		return BUFFER_SUCCESS;
	}
}

//データ取り出し処理
BUFFER_STATUS cureRingBufferU8Dequeue(RingBufferU8 *rbuf, uint8_t *ret)
{
	if(rbuf->idx_front == rbuf->idx_rear){//バッファが空
		return BUFFER_FAILURE;
	}else{
		*ret = (rbuf->buffer[rbuf->idx_rear]);
		rbuf->idx_rear++;
		rbuf->idx_rear &= (rbuf->length -1);
		return BUFFER_SUCCESS;
	}
}

ちなみに、ハイライトした行の演算(front, rearを要素数の範囲で循環させる演算)には、ビット演算を使い高速化しています。例えば下記の2つは同じ結果になりますが、1つ目の方が速いです。ただし要素数を2のべき乗としなければなりません。


//ビット演算(速い)
idx_front &= (length -1);

//条件分岐(遅い)
if( idx_front >= length ){
	idx_front -= length;
}

//ビット演算の性質上、要素数lengthには「2のべき乗」という制限あり。

 

バッファ操作用関数は、MIDI操作用関数群であるcuremidi.h/c内でwrapして使っています。下記のように、cureMidiInit()関数内でバッファ領域を初期化しておき、2.2節のようにcureMidiBufferEnqueue()関数を呼び出すことで、バッファにデータを蓄積できます。


/*curemidi.h, curemidi.c*/

//バッファ長さ
#define MIDIBUFFER_LENGTH (1024)

//バッファ用構造体
RingBufferU8 rxbuf;

//初期化処理
FUNC_STATUS cureMidiInit()
{
	//(中略)

	if( BUFFER_FAILURE == cureRingBufferU8Init(&rxbuf, MIDIBUFFER_LENGTH) ){
		return FUNC_ERROR;
	}
	
	//(中略)
	
	return FUNC_SUCCESS;
}

//データ蓄積処理のラップ関数
BUFFER_STATUS cureMidiBufferEnqueue(uint8_t* inputc)
{
	return cureRingBufferU8Enqueue(&rxbuf, inputc);
}
//※関数マクロにした方が高速化できると思いますが、読みやすさ優先で…

//他のラップ関数は割愛

 

ちなみにBUFFER_STATUS, FUNC_STATUSは、それぞれバッファ用、一般関数用のエラーフラグであり、enumで定義しています。コーディングの見やすさのために用意しています。

typedef enum{
	BUFFER_FAILURE,BUFFER_SUCCESS
}BUFFER_STATUS;

typedef enum{
	FUNC_ERROR,FUNC_SUCCESS
}FUNC_STATUS;

 

今回はここまで。
次回は、MIDIメッセージの解析処理について紹介します。


[STM32F7で作るMIDI音源] その3 ソフトウェアの概要

自作MIDI音源「CureSynth」製作記事の一覧はこちら

前回は、自作MIDI音源「CureSynth」の音源部の構成について紹介しました。
今回から、組み込みソフトウェアの紹介に移ります。

はじめに

本稿では、コアとなる処理を考え、「メインループ内で実施するか否か」を決め、アバウトなソフト構造を示します。
「CureSynth」のコアとなる処理は、次の5つです。

  1. MIDIメッセージの受信
    • UART(USART)割り込みで実施
  2. MIDIメッセージの解析
    • メインループ内で実施
  3. 音データの生成
    • タイマ割り込みで、サンプリング周波数ごとに実施
  4. DACの制御
    • DMAによる転送で実施
    • タイマ割り込みで、サンプリング周波数ごとに実施(バッファの書き換えのみ)
  5. ディスプレイの制御
    • DMAによる転送で実施
    • メインループ内で実施(バッファの書き換えのみ)

1.MIDIメッセージの受信

MIDIインタフェースは非同期シリアル通信なので、シリアル通信に関する一般的な手法で受信ができます。受信方法としては「ポーリング」と「UART(USART)割り込み」がありますが(※)、負荷を少しでも下げるために割り込みによる受信をします。MIDIメッセージはバイト単位なので、1バイト(8bit)受信するごとに割り込みが発生するようにします。

また、割り込み処理内でリングバッファ(FIFO)に蓄積し、処理の安定化を図ります。

※STM32F7ではDMAによる受信にも対応していますが、MIDIでは受信データ規模が小さく、割り込みと比較してメリットが少ないと思われるので、今回は不採用です。良い使い方があるのかもしれませんが…

2.MIDIメッセージの解析

受信したMIDIメッセージの解析は、割り込み処理内ではなくメインループ内で行います。理由は、MIDIメッセージは複数のバイト列で意味をなすため、1バイト受信するごとに処理する必要がないからです。

3.音データの生成

MIDIメッセージを解析した情報をもとに、音データを生成します。

ここで、MIDIメッセージには時間の情報が含まれないことに注意します。MIDIでは「C音の発音を開始」「C音の発音を終了」といった情報は伝送できますが、発音開始時刻・終了時刻を伝送することはできません。そのため、MIDI音源側で予め音データを演算しておくことはできず、音データはその都度生成する必要があります。

従って、タイマ割り込みで、音データを都度生成するのが妥当でしょう。このとき、タイマ割り込みの周期がサンプリング周波数になります。

4.DACの制御

DACには、部品箱にストックのある「PCM5102A」を使用しました。STM32F7にはDACが内蔵されていますが、量子化bit数が12bitと小さいことや、ノイズフロアが大きくS/N比が悪いことから、オーディオ用途に使うには厳しく、外部DACが必要と判断しました。

PCM5102Aは、音声データを「I2S信号」として入力すれば発音してくれるので、制御が簡単です。MCUのペリフェラルにはI2S出力があり、HALの関数を呼ぶだけで操作できます。

ただし、I2S信号を安定的に供給しなければなりません。I2S信号が途切れると、PCM5102Aでは出力がMUTE状態となるため、プチノイズが発生します。

そこで、音声データの転送にDMAを用いることで、安定化を図ります。具体的には、リングバッファ(FIFO)領域を用意し、I2Sペリフェラルに対して繰り返し転送するような設定をしておきます。またタイマ割り込みを用い、バッファの書き換えをサンプリング周波数ごとに行います。

5.ディスプレイの制御

ディスプレイモジュールには、AliExpressで購入したOLEDを用いました。
リンクはこちら→1PCS 1.3″ OLED module blue color IIC I2C …

こちらには「SH1106」というICが使われており、I2C経由で表示内容の制御ができます。

負荷の軽減のため、ディスプレイの制御にもDMAを用いることにしました。DACと同様に、リングバッファ領域を用意しています。DACの制御と異なるのは、周期にこだわる必要が無い点です(というより、DACの制御にリソースを割きたい)。そこでバッファ領域の書き換えはメインループ内で行っています。

6.ソフト構造の決定

ソフトの構造

1.~4.の内容をもとに、上図のとおりソフト構造を決めます。めちゃいい加減な図ですが…。

MIDIメッセージの解析部(cureMidi)、音源部(cureSynth)、ディスプレイコントロール(cureDisplay)は、メインプログラム(main.c)から呼び出すことにします。

また、MIDIメッセージを解析し、音源部に伝える必要があるため、MIDIメッセージの解析部から音源部を呼び出します。

さらにMIDI音源部では、受信したMIDIメッセージをバッファに入れる/読み込む操作が必要ですので、バッファ制御部(cureBuffer)を呼び出しています。

ところで本稿では省きましたが、音源部からバッファ制御部を呼び出している理由は、ディレイ・エフェクトを実装するためです。

 

適当な説明になってしまいましたが、設計思想が伝われば幸いです。伝わるかな…?

今回はここまで。
次回はMIDIメッセージの受信とリングバッファについて紹介します。


[STM32F7で作るMIDI音源] その2 音源部の構成

自作MIDI音源「CureSynth」製作記事の一覧はこちら

前回は、自作MIDI音源「CureSynth」の開発用ボードについて紹介しました。
今回は、音源部の構成について紹介します。

1.全体構成

音源部の全体図(オペレータをn基、MIDIトラックを16chとした)

こちらが音源部の全体図です。

1つの音(例えばピアノの”ド”の音)を発生させる部分を「オペレータ(Operator)」と言い、オペレータの個数がいわゆる最大同時発音数となります。オペレータの出力信号は、それぞれ1つの「MIDIトラック(Track)」に対して割り当てられています。このときMIDIトラックには、1つまたは複数のオペレータ出力信号が入力されることになります。そしてMIDIトラックでは、取り込んだ信号を全て加算した上で、トラックに対する演算(3節で後述)を適用後、出力することになります。

このような構成にした理由は、MIDIの仕様によるものです。一般的なMIDI音源には、1つの音を出す上で必要な演算(例えば音色、音階、Velocityなど)と、各トラックに対して必要な演算(例えばExpression、Delayなど)があるため、オペレータとトラックを分けて考えると都合が良いのです。

例として、ピアノの3和音(C, E, G)と、ギターの1音(B)を同時にならす場合を考えてみます。ただし、ギターにはディレイ・エフェクトを掛けるものとします。このとき、ピアノとギターのトラックを分け、ギタートラックにのみディレイ・エフェクトを掛けるのが適切でしょう。そこで、オペレータ1~3にそれぞれピアノのC, E, G音、オペレータ4にギターのB音を発音させ、トラック1にオペレータ1~3、トラック2にオペレータ4を割り当てたあと、トラック2にディレイ・エフェクトを掛ければ実現できます。

2.オペレータの構成

オペレータ内部の構成

次に、オペレータ内部の構成です。

オペレータ内部には「波形発生器(Wave Generator)」があり、ここでsin波、矩形波、ノコギリ波、三角波、ノイズ音を生成します。そして波形発生器の出力を、「リングモジュレータ(Ring Modulator)」で変調します。さらにADSR(Attack, Delay, Sustain, Release)や、音程調整(Pitch shift)を行ったあと、出力音の強さ(Velocity)を調整した上で、各トラックに出力信号を割り当てます。

3.MIDIトラックの構成

MIDIトラック内部の構成

最後に、MIDIトラックの構成です。

各MIDIトラックでは、割り当てられたオペレータ出力信号を加算し(図のMixed operator)、演算を適用していきます。適用順によって音質が変化するため、慎重に検討する必要がありますが、ひとまず、音量コントロール→Distortion→フィルタ(LPF/HPF)→Panでステレオ化→Delayの順に適用することにします。

 

今回はここまで。次回からはようやく、マイコン(STM32F7)への実装について紹介します。
次回へ続く