LoginSignup
2
4

More than 1 year has passed since last update.

100円マイコンでPSG音楽を演奏~

Last updated at Posted at 2021-11-16

何か面白い応用をやってみよう~

R8C/Mxx の内部機能の解説ばかりでは、そろそろ退屈しているかもしれないので、ここらで、何かガジェットに応用できるアプリを紹介してみようと思います。

今回考えたのは、付加するハードが少なく単純で、何かダイナミックな動作が出来るガジェットとしてPSGで音楽演奏をやってみました。

まず、単純に思いつくのは、内部タイマーで生成した周波数で圧電ブザーを鳴らすようなものだと思います。

ですが、これでは、あまりにもショボイですし、20MHz で動作するマイコンのガジェットとして相応しくないと思えます。

そこで、PWM 変調を使い、PWM 出力を8ビットの D/A コンバーターに見立て、波形ストリームを出力する物を考えてみました。

ただ、波形ストリームの演算は負荷が大きく、あまり高度な音源をソフトシンセ的に鳴らすには100円のマイコンには厳しいとこです。

そこで、ファミコン音源並の PSG 音源をソフトで生成して実験してみました。

音楽だけにリソースを割けば、もう少し高度な事も考えられますが、何か別のガジェットにも使うには頃合いと思います。

PWM 変調

R8C/Mxx のタイマーCチャネルは、PWM出力が出来る仕様です。
※タイマーCの解説は、また改めて行う予定です。

音楽の場合、8オクターブで、「ラ」の音は3520Hzなので、PWM変調の周期はそれより大きい周波数にする必要があります。
タイマーCは20MHzで駆動するので、8ビットの分解能とするなら~
20MHz / 4 / 256 = 19531Hz が良さそうです。
※20MHz / 8 / 256 = 9765Hz でも可能ですが、やはり品質は低くなります、しかし、処理負荷が少なく、メモリの使用量も少なくなりますので、リソースを節約する場合には妥協する事も出来ます。

	static constexpr uint16_t TRC_LIM = 256;  // サンプリング分解能(256固定)
	// サンプリング周期を変更すると、アタックやリリースを変更する必要がある。
// #define LOW_PROFILE
#ifdef LOW_PROFILE
	static constexpr auto TRC_DIV = device::trc_base::DIVIDE::F8;  // TRC の分周器パラメーター(1/8)
	static constexpr uint16_t SAMPLE = F_CLK / 8 / TRC_LIM;  // サンプリング周期(F_CLK は CPU の動作周波数)
	static constexpr uint16_t BSIZE = 256;  // SAMPLE / TICK * 2 以上で、2のn乗倍になる値
#else
	// サンプリングを倍にした、より高音質な設定
	static constexpr auto TRC_DIV = device::trc_base::DIVIDE::F4;  // TRC の分周器パラメーター(1/4)
	static constexpr uint16_t SAMPLE = F_CLK / 4 / TRC_LIM;  // サンプリング周期(F_CLK は CPU の動作周波数)
	static constexpr uint16_t BSIZE = 512;  // SAMPLE / TICK * 2 以上で、2のn乗倍になる値
#endif
	static constexpr uint16_t TICK = 100;	// サンプルの楽曲では、100を前提にしている。
	static constexpr uint16_t CNUM = 3;		// 同時発音数(大きくすると処理負荷が増えるので注意)
	typedef utils::psg_base PSG;
	typedef utils::psg_mng<SAMPLE, TICK, BSIZE, CNUM> PSG_MNG;
	PSG_MNG	psg_mng_;

アプリでは、上記のように切り替えが簡単に出来るようにしてあります。

PWM 出力に、抵抗とコンデンサなどの簡単なローパスフィルターを接続すれば、簡単にアナログ的な出力が得られます。
※品質に関しては、「それなり」です。

オーディオの出力としては、PWM が50%の時、無音で、0%でマイナス最大、100%でプラス最大とします。
なので、マイコンの電源電圧の中間電位をオーディオ信号の「0V」とする必要があります。

PWM 信号は TRCIOB 端子(18番)から出力されます、R8Cは3.3V駆動でテストしています。

RCフィルターの参考回路:
audio_if.png

矩形波50%(440Hz):
SDS00001.png

三角波(440Hz)
SDS00002.png

  • かなりリップルがあるので、もう少しC(0.1uF)を増やした方が良さそうです。
  • このリップルは、周波数が高いので、あまり聞こえないのですけど・・

音源の仕様

音源としては、PSG 並の簡単な物を考えます。

  • 処理負荷を低く抑える。(「音」を利用するガジェットで利用可能なように)
  • 実装がある程度簡単で、それなりの音楽が演奏出来る。(ファミコン並)
  • メモリー消費が少ない(PWMストリーム再生で512バイトを消費してしまうけど・・)

出力可能な波形

とりあえず、大きく二つの波形出力をサポートしました。

  • 矩形波(デューティーが25%、50%、75%)
  • 三角波

※効果音などに「ノイズ」が必要ですが、それは、今後のアップデートで追加しようと思います。

ボリューム

0~128までとしてあります。
※内部の構成に都合が良い値となっています。
※実際には、7ビットの分解能はありません。

チャネル数

とりあえず同時3チャネル。

※テンプレートクラスなので、自由に変えられますが、多くのチャネルを使うと、処理負荷が大きくなります。

演奏の分解能

演奏のスコアを処理する単位です。

100Hz

※これもテンプレートなので自由に変えられます。
※ゲームでは、画面の更新が60Hzなので、60Hzが使われていましたが、現在は、CPUの能力が高いので、より短い周期が使われています。

この分解能は、スコアの記述やテンポに直接影響するので、最初に決めたら自由に変更する事は出来ません。

100Hzでは、0.01秒単位で計算しやすく、小さなシステムに丁度良い周期だと思います。


演奏の方法論(俗に言うサウンドドライバーとは?)

自分は、小学生の音楽の授業には良い思い出がありません・・
※リコーダーの練習が嫌で、古典音楽にあまり興味がありませんでした・・(今はクラシックから初音ミクまで色々聴いています~w)
今になって思えば、あの頃もっとちゃんと勉強しておけばと思う事が多々あります、楽器もちゃんとやっていれば・・・

最初にサウンドドライバーらしき物を実装したのは、社会に出てからですが、その時に覚えた知識が今も生きています。
ゲームを作り、実際にサウンドをサポートするとなると、音楽理論や楽譜、楽器、MIDI、サウンド系ツールなど、色々な知識が必要です。

12平均音階率

世の中にある「音楽」は、ほぼ、この理論で創られています。

「ドレミファソラシド」ってやつですw
この中で「半音」とゆーのが出てきます、ピアノで言う「黒鍵」です。

しかし、実際(数学的)には、半音を含めた音階は、平均的に並んでいます。
※「ミ」の半音上が「ファ」で、この間には「黒鍵」はありません。
※そもそも、「半音」と言う表現は適切では無いと思います。

各音階の周波数を計算するには、12乗すると2になる定数を求めて、それを乗算していくだけです。

12回掛け算すると周波数が2倍(オクターブ)になります。

※定数「1.059463094」を12乗すると「2」になります。

		// 12 平均音階率の計算:
		// 2^(1/12) の定数、12乗すると2倍(1オクターブ上がる)となる。
		static constexpr uint16_t key_tbl_[12] = {
			static_cast<uint16_t>((3520 * 65536.0 * 1.000000000) / SAMPLE),  ///< A  ラ
			static_cast<uint16_t>((3520 * 65536.0 * 1.059463094) / SAMPLE),  ///< A#
			static_cast<uint16_t>((3520 * 65536.0 * 1.122462048) / SAMPLE),  ///< B  シ
			static_cast<uint16_t>((3520 * 65536.0 * 1.189207115) / SAMPLE),  ///< C  ド
			static_cast<uint16_t>((3520 * 65536.0 * 1.25992105 ) / SAMPLE),  ///< C#
			static_cast<uint16_t>((3520 * 65536.0 * 1.334839854) / SAMPLE),  ///< D  レ
			static_cast<uint16_t>((3520 * 65536.0 * 1.414213562) / SAMPLE),  ///< D#
			static_cast<uint16_t>((3520 * 65536.0 * 1.498307077) / SAMPLE),  ///< E  ミ
			static_cast<uint16_t>((3520 * 65536.0 * 1.587401052) / SAMPLE),  ///< F  ファ
			static_cast<uint16_t>((3520 * 65536.0 * 1.681792831) / SAMPLE),  ///< F#
			static_cast<uint16_t>((3520 * 65536.0 * 1.781797436) / SAMPLE),  ///< G  ソ
			static_cast<uint16_t>((3520 * 65536.0 * 1.887748625) / SAMPLE),  ///< G#
		};

このプログラムでは、8オクターブのテーブルだけ用意してあり、シフト演算で、低いオクターブのパラメーターを計算しています。

楽器の調音では、和音の響きを良くする為、微妙に変えているようで、色々な主義があるようです。

エンベロープ

一般的な楽器には、音の強弱があり、電子楽器でそれを模倣する仕組みとしてエンベロープがあります。

このPSG音源でも、非常に簡単な仕組みとして、ATTACK、RELEASE、と二つのパラメーターを使って強弱を表現しています。

Envelope.png

標準値として、以下のように初期化されています。

				attack_ = 150;  // アタック初期値
				release_ = 10; // リリース減衰初期値
				rel_frame_ = 6; // リリース TICK 標準

計算式として、以下のようなもので、1/100 秒毎に計算されます。
※TICK が 100Hzの場合

					if(rel_count_ > 0) {
						rel_count_--;
						// +エンベロープ
						env_ += static_cast<uint16_t>((volume_ - env_) * attack_) >> 8;
					} else {
						// -エンベロープ
						uint8_t n = static_cast<uint16_t>(env_ * release_) >> 8;
						if(n > 0) env_ -= n;
						else env_ = 0;
					}

C++ による演奏データ構築

演奏データは、C++ を活用して構築しています。

一般的な スタンダード MIDI データのような物は、大掛かり過ぎて、データ量も大きくなるので小回りが利きません。
そこで、ファミコン時代に良く使っていた手法を応用した簡易な構造と構成にしています。

  • 音階データと、音長データ
  • 制御データ

音階データ

音階データは、88鍵のピアノに相当するもので、「enum class KEY」として定義してあります。

		//+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++//
		/*!
			@brief  12平均音階率によるキーテーブル @n
					(0)27.5, (1)55, (2)110, (3)220, (4)440, (5)880, (6)1760, (7)3520
		*/
		//+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++//
		enum class KEY : uint8_t {
			A_0,		///< A  ラ  27.5Hz (0)
			As0,		///< A#
			Bb0 = As0,	///< Bb
			B_0,		///< B  シ
			C_1,		///< C  ド
			Cs1,		///< C#
			Db1 = Cs1,	///< Db
			D_1,		///< D  レ
			Ds1,		///< D#
			Eb1 = Ds1,	///< Eb
			E_1,		///< E  ミ
			F_1,		///< F  ファ
			Fs1,		///< F#
			Gb1 = Fs1,	///< Gb
			G_1,		///< G  ソ
			Gs1,		///< G#
...

約8オクターブ分あります。
※フラット(b)も定義してあります。

音長データ

音長は、整数となっていて、テンポと、TICK(100Hz)により計算されます。

音調×256÷テンポ×(1/100)秒

制御データ

楽譜全体を制御するコマンドを設定して実装してあります。

		enum class CTRL : uint8_t {
			TR = 89,	///< (2) トランスポーズ, num(-88 ~ 0 ~ 88)
			SQ25,		///< (1) 波形 SQ25
			SQ50,		///< (1) 波形 SQ50
			SQ75,		///< (1) 波形 SQ75
			TRI,		///< (1) 波形 TRI
			VOLUME,		///< (2) ボリューム, num(0 ~ 128)
			TEMPO,		///< (2) テンポ, num(1 ~ 255)
			FOR,		///< (2) ループ開始, num(1 ~ 255)
			BEFORE,		///< (1) ループ終了
			END,		///< (1) 終了
			REPEAT,		///< (1) リピート
			ATTACK,		///< (2) 音のアタック, num(0 ~ 255)
			RELEASE,	///< (3) 音のリリース, frame(n), num(0 ~ 255)
			TEST,
		};

※KEYが88、休符1、89から始まり、255までが制御コマンドとして登録可能です。

union と constexpr を利用したスコア構築

楽譜データは、単純な整数としないで、ある程度構造化する事で、間違いを減らし、見やすいコードとなります。

そこで、C++ の機能を利用して、データ列を定義します。

「enum class」では、型が明確になり便利になりましたが、色々な型を混ぜるような使い方が出来ません。
そこで、「union」を使い、以下のようなクラスを定義する事で、自由度を受け付けるようにしています。

  • コンストラクターの受け取る型が異なる為、型の異なる値を格納出来ます。
  • union では、最も大きなサイズが採用されるので、uint8_t 型を使っています。
  • 16 ビット以上の数値を設定するような場合、また、別の手法が必要となります。
  • #define を駆使すると、このような場合に無理やりなデータ列を生成する事も可能ですが、安全性や可読性が犠牲になると思われます。(C++では使わない!)
		struct SCORE {
			union {
				KEY		key;
				CTRL	ctrl;
				uint8_t	len;
			};
			constexpr SCORE(KEY k) noexcept : key(k) { }
			constexpr SCORE(CTRL c) noexcept : ctrl(c) { }
			constexpr SCORE(uint8_t l) noexcept : len(l) { }
		};

C++11 以降、コンパイル時定数で、ROM 領域にデータを配置するには「constexpr」を使う事になりました。
※その為、コンストラクターに「constexpr」を付加しています。

この細工により、スコアを構造的に記述出来ます。
このデータ列は、「constexpr」を使っている為、コンパイル時に決定され、ROM 領域に配置されます。

	constexpr PSG::SCORE score0_[] = {
		PSG::CTRL::VOLUME, 128,
		PSG::CTRL::SQ50,
		PSG::CTRL::TEMPO, 80,
		// 1
		PSG::KEY::Q,   8,
		PSG::KEY::E_5, 8,
		PSG::KEY::D_5, 8,
		PSG::KEY::E_5, 8,
		PSG::KEY::C_5, 8,
		PSG::KEY::E_5, 8,
		PSG::KEY::B_4, 8,
		PSG::KEY::E_5, 8,

※単なる「const」だと、データ列は、RAM 上に配置され、起動時に ROM 領域から RAM 領域へコピーされます。

psg_mng クラスの定義

psg_mng テンプレートクラスは、サンプリングにより、8ビット幅のデータ列を生成します。
生成されたデータ列は、タイマーCのPWMデータとして順次送られます。

	static constexpr uint16_t TRC_LIM = 256;  // サンプリング分解能(256固定)
	// サンプリング周期を変更すると、アタックやリリースを変更する必要がある。
// #define LOW_PROFILE
#ifdef LOW_PROFILE
	static constexpr auto TRC_DIV = device::trc_base::DIVIDE::F8;  // TRC の分周器パラメーター(1/8)
	static constexpr uint16_t SAMPLE = F_CLK / 8 / TRC_LIM;  // サンプリング周期(F_CLK は CPU の動作周波数)
	static constexpr uint16_t BSIZE = 256;  // SAMPLE / TICK * 2 以上で、2のn乗倍になる値
#else
	// サンプリングを倍にした、より高音質な設定
	static constexpr auto TRC_DIV = device::trc_base::DIVIDE::F4;  // TRC の分周器パラメーター(1/4)
	static constexpr uint16_t SAMPLE = F_CLK / 4 / TRC_LIM;  // サンプリング周期(F_CLK は CPU の動作周波数)
	static constexpr uint16_t BSIZE = 512;  // SAMPLE / TICK * 2 以上で、2のn乗倍になる値
#endif
	static constexpr uint16_t TICK = 100;	// サンプルの楽曲では、100を前提にしている。
	static constexpr uint16_t CNUM = 3;		// 同時発音数(大きくすると処理負荷が増えるので注意)
	typedef utils::psg_base PSG;
	typedef utils::psg_mng<SAMPLE, TICK, BSIZE, CNUM> PSG_MNG;
	PSG_MNG	psg_mng_;

タイマーCの定義

タイマーCはPWMを生成し、TRCIOB に出力するようにしてあります。
割り込み処理により、PSG のストリームからデータを取り出して、PWM の Duty を変える為、レジスターに書き込みます。
ストリームバッファは、リングバッファになっていて、PSG で生成するデータと、PWM 割り込みで、読み出すデータがぶつからないようにしています。

volatile uint16_t pwm_pos_;

	class pwm_task {
	public:
		void operator () ()
		{
			device::TRCGRB = psg_mng_.get_wav((pwm_pos_ + (BSIZE / 2)) & (BSIZE - 1));
			++pwm_pos_;
			pwm_pos_ &= BSIZE - 1;
		}
	};

	typedef device::trc_io<pwm_task> TIMER_C;
	TIMER_C	timer_c_;

初期化

初期化では、楽譜の運用で利用する 100Hz のタイマーB、PWM を生成するタイマーCチャネルを初期化します。

	// タイマーB初期化
	{
		uint8_t ir_level = 2;
		timer_b_.start(TICK, ir_level);
	}

	// タイマーC初期化PSG用
	{
		// SAMPLE 周期
		utils::PORT_MAP(utils::port_map::P12::TRCIOB);
		uint8_t ir_lvl = 2;
		timer_c_.start_psg(TRC_LIM, TRC_DIV, ir_lvl);
	}

メインループ

  • スコア構造体のポインターを、サウンドマネージャーに登録します。
  • メインループは、100Hzで動かしておき、PSGのデータ生成を行いバッファに格納します。
  • ここで、重要なのは、100Hzで動作するループは、PWMのループと非同期になっています。
  • PWMで使われたデータ量を取得して、PSGのデータ生成を行います。
  • この工夫により、データが途切れる事なく、溢れる事も無く、潤沢に必要な分だけデータを生成する事が出来ます。

	psg_mng_.set_score(0, score0_);
	psg_mng_.set_score(1, score1_);

	auto pos = pwm_pos_;
	uint8_t delay = 100;
	while(1) {
		timer_b_.sync();

		auto org = pwm_pos_;
		auto n = org - pos;
		psg_mng_.render(n & (BSIZE - 1));
		pos = org;

		if(delay > 0) {
			delay--;
		} else {
			psg_mng_.service();
		}
	}

プロジェクト

まとめ

C++ では、テンプレートを活用する事で、柔軟で構造的なモジュールとして実装する事が出来るので、再利用がしやすいと思います。

psg_mng.hpp クラスは、RL78、RX マイコンでも同じように利用可能です。

C++14 以降のコンパイラが使える環境なら、別のプラットホームでも利用可能だと思います。


サンプルの楽曲

サンプルとして「ドラゴンクエスト1・ラダトーム城」(ピアノバージョン)のスコアを入れてあります。
※途中まで・・

この楽曲データを参考にすれば、他の楽曲でも、又は、自分で作曲した楽譜でも、要領がつかめると思います。
※制御コマンドは殆ど使っていません。
※制御コマンドは今後改修の予定があります。


参考リンク

前の記事:

開発環境の構築など:

format テンプレートクラス:

constexprとconstを正しく使い分ける:

楽譜の読み方:

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4