13
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Siv3DAdvent Calendar 2022

Day 18

ソフトウェアシンセサイザーを作る その1:サイン波でMIDIを再生する

Last updated at Posted at 2022-12-18

はじめに

この記事は、シンセによくある機能を自分で実装することで、音の作り方についての理解を深めようと作成したチュートリアルです。そのため、理論的な部分も含めできるだけ省略せずに説明していきたいと思います。

実装するのはウェーブテーブル方式のリアルタイムシンセサイザーです。構成はこのようにしました。
構成.png

VST プラグインについては扱いません。MIDI 形式の譜面データを読み込んでサウンドが鳴るようなアプリケーションとして実装します。

ソースコード

記事中の各ステップで作成したコードはこちらにまとめました。

開発環境

  • Visual Studio 2022
  • OpenSiv3D v0.6.6

記事の構成

全部で4つの記事になる予定です。この記事は導入として、サイン波を使って MIDI 譜面を再生するところまで行います。リポジトリでの以下のファイルに相当する実装を順に追っていきます。

  • Chapter1_1_SinWave.cpp
  • Chapter1_2_Envelope.cpp
  • Chapter1_3_Chord.cpp
  • Chapter1_4_Synthesizer.cpp
  • Chapter1_5_MIDI.cpp

1.サイン波を再生する

指定された周波数と振幅でサイン波を計算するコードは次のように書けます。

sin(Math::TwoPi * frequency * t) * amplitude;

tは秒数で、1サンプルあたりに サンプリング周波数の逆数 だけ時間を進めます。以降ではサンプリング周波数は全て 44100 [Hz] 固定とします。

例として、サイン波の波形を生成して返す関数を以下に定義します。

# include <Siv3D.hpp> // OpenSiv3D v0.6.6

Wave RenderWave(uint32 seconds, double amplitude, double frequency)
{
	const auto lengthOfSamples = seconds * Wave::DefaultSampleRate;

	Wave wave(lengthOfSamples);

	for (uint32 i = 0; i < lengthOfSamples; ++i)
	{
		const double sec = 1.0f * i / Wave::DefaultSampleRate;
		const double w = sin(Math::TwoPiF * frequency * sec) * amplitude;
		wave[i].left = wave[i].right = static_cast<float>(w);
	}

	return wave;
}

これを使って、サイン波を3秒間再生するプログラムを以下のように書けます。

// GUIの描画用
const auto SliderHeight = 36;
const auto SliderWidth = 400;
const auto LabelWidth = 200;

void Main()
{
	double amplitude = 0.2;
	double frequency = 440.0;

	uint32 seconds = 3;

	Audio audio(RenderWave(seconds, amplitude, frequency));
	audio.play();

	while (System::Update())
	{
		Vec2 pos(20, 20 - SliderHeight);
		SimpleGUI::Slider(U"amplitude : {:.2f}"_fmt(amplitude), amplitude, 0.0, 1.0, Vec2{ pos.x, pos.y += SliderHeight }, LabelWidth, SliderWidth);
		SimpleGUI::Slider(U"frequency : {:.0f}"_fmt(frequency), frequency, 100.0, 1000.0, Vec2{ pos.x, pos.y += SliderHeight }, LabelWidth, SliderWidth);

		if (SimpleGUI::Button(U"波形を再生成", Vec2{ pos.x, pos.y += SliderHeight }))
		{
			audio = Audio(RenderWave(seconds, amplitude, frequency));
			audio.play();
		}
	}
}

生成した波形データを Audio クラスに渡して再生を行います。また、振幅と周波数を変更したときはボタンを押すことで波形を再生成するようにしました。

ここまでのコード

2.ADSR エンベロープを実装する

キーのそれぞれの入力状態における音量の時間変化をエンベロープと呼びます。

  • キーを押した直後
  • 押し続けている間
  • 離した後

同じ波形でもエンベロープの形によってピアノっぽい音やストリングスっぽい音になったりします。ほとんどのシンセは、これを設定するエンベロープジェネレーターを備えています。細かい設定はシンセによって異なりますが、少なくとも以下の4つのパラメータによって構成されています。

envelope.png

  • attackTime : 音が鳴り始めてからピークに達するまでの時間
  • decayTime : ピークに達してからsustainLevelまで減衰するのにかかる時間
  • sustainLevel : 音を鳴らし続けている間の音量レベル
  • releaseTime : 音を止めてから減衰しきるまでにかかる時間
struct ADSRConfig
{
	double attackTime = 0.1;
	double decayTime = 0.1;
	double sustainLevel = 0.6;
	double releaseTime = 0.4;
};

この4つのパラメータを使用して、エンベロープジェネレーターを実装することができます。

EnvGenerator の実装

EnvGenerator クラスの全体像は以下のようになります。enum で定義した各状態を update 関数の中で遷移させることで更新を行います。

class EnvGenerator
{
public:

	enum class State
	{
		Attack, Decay, Sustain, Release
	};

    // ノートの入力が終了した
    void noteOff()
	{
		if (m_state != State::Release)
		{
			m_elapsed = 0;
			m_state = State::Release;
		}
	}

    // エンベロープの更新
	void update(const ADSRConfig& adsr, double dt)
	{
		switch (m_state)
		{
			// m_currentLevel の更新、ステート遷移を行う
		}

		m_elapsed += dt;
	}

    // 現在のレベルを取得する
	double currentLevel() const
	{
		return m_currentLevel;
	}

private:

	State m_state = State::Attack;
	double m_elapsed = 0; // ステート変更からの経過秒数
	double m_currentLevel = 0; // 現在のレベル [0, 1]
};

m_elapsed は遷移してからの経過時間で、遷移するたびに0にリセットします。更新処理のswitch文の中身の実装について以下で説明します。

Attack

0.0 から 1.0 まで attackTime かけて増幅します。attackTime を超えていたら、そのまま次のDecay処理に移ります。

case State::Attack:
	if (m_elapsed < adsr.attackTime)
	{
		m_currentLevel = m_elapsed / adsr.attackTime;
		break;
	}
	m_elapsed -= adsr.attackTime;
	m_state = State::Decay;
	[[fallthrough]]; // Decay処理にそのまま続く

Decay

1.0 から sustainLevel まで decayTime かけて減衰します。これについても decayTime を超えていたら、そのまま次の Sustain 処理に移ります。

case State::Decay:
	if (m_elapsed < adsr.decayTime)
	{
		m_currentLevel = 
            Math::Lerp(1.0, adsr.sustainLevel, m_elapsed / adsr.decayTime);
		break;
	}
	m_elapsed -= adsr.decayTime;
	m_state = State::Sustain;
	[[fallthrough]]; // Sustain処理にそのまま続く

Sustain

noteOff() が呼び出されるまで sustainLevel を維持します。

case State::Sustain:
	m_currentLevel = adsr.sustainLevel;
	break;

Release

sustainLevel から 0.0 まで releaseTime かけて減衰します。releaseTime を超えたら常に 0.0 を返すようにします。

case State::Release:
	m_currentLevel = m_elapsed < adsr.releaseTime
		? Math::Lerp(adsr.sustainLevel, 0.0, m_elapsed / adsr.releaseTime)
		: 0.0;
	break;

上記の処理は Attack や Decay 中に noteOff() が呼ばれることを考慮していません。これを考慮する場合は、 Release 処理の開始点を adsr.sustainLevel ではなく、noteOff() が呼ばれた時点の m_currentLevel を別の変数に保存して、そこから 0.0 まで減衰させるようにすると滑らかに繋がります。

エンベロープを適用した波形生成

生成した波形に EnvGenerator を適用して音量を変化させるサンプルです。

Wave RenderWave(uint32 seconds, double amplitude,
    double frequency, const ADSRConfig& adsr)
{
	const auto lengthOfSamples = seconds * Wave::DefaultSampleRate;

	Wave wave(lengthOfSamples);

	// 0サンプル目でノートオン
	EnvGenerator envelope;

	// 半分経過したところでノートオフ
	const auto noteOffSample = lengthOfSamples / 2;

	const float deltaT = 1.0f / Wave::DefaultSampleRate;
	float time = 0;
	for (uint32 i = 0; i < lengthOfSamples; ++i)
	{
		if (i == noteOffSample)
		{
			envelope.noteOff();
		}

		const auto w = sin(Math::TwoPiF * frequency * time)
			* amplitude * envelope.currentLevel();
		wave[i].left = wave[i].right = static_cast<float>(w);

        // エンベロープの更新
		time += deltaT;
		envelope.update(adsr, deltaT);
	}

	return wave;
}

サイン波を生成した後に envelope.currentLevel() を掛けることでエンベロープを適用しています。また、サンプルが noteOffSample に到達したら envelope.noteOff() を呼んで Release 状態に遷移させています。

void Main()
{
	double amplitude = 0.2;
	double frequency = 440.0;

	uint32 seconds = 3;

	ADSRConfig adsr;
	adsr.attackTime = 0.1;
	adsr.decayTime = 0.1;
	adsr.sustainLevel = 0.8;
	adsr.releaseTime = 0.5;

	Audio audio(RenderWave(seconds, amplitude, frequency, adsr));
	audio.play();

	while (System::Update())
	{
		Vec2 pos(20, 20 - SliderHeight);
		SimpleGUI::Slider(U"amplitude : {:.2f}"_fmt(amplitude), amplitude, 0.0, 1.0, Vec2{ pos.x, pos.y += SliderHeight }, LabelWidth, SliderWidth);
		SimpleGUI::Slider(U"frequency : {:.0f}"_fmt(frequency), frequency, 100.0, 1000.0, Vec2{ pos.x, pos.y += SliderHeight }, LabelWidth, SliderWidth);

		adsr.updateGUI(pos);

		if (SimpleGUI::Button(U"波形を再生成", Vec2{ pos.x, pos.y += SliderHeight }))
		{
			audio = Audio(RenderWave(seconds, amplitude, frequency, adsr));
			audio.play();
		}
	}
}

エイリアシング

デジタル波形で表現できる最大周波数はサンプリング周波数の半分の値で、これをナイキスト周波数と呼びます。波形にナイキスト周波数を超えるような瞬間的な変化があると、再生したときに意図しない周波数のノイズが生じます。このノイズのことをエイリアシング(折り返し雑音)と呼びます。

aliasing.png

エイリアシングあり

試しにアタックとリリースを0にして再生すると、音の開始と終了でプツッというノイズが聞こえるのが確認できます。

adsr.attackTime = 0.0;
adsr.releaseTime = 0.0;

エイリアシングなし

これを 0.01 にするとノイズが消えることが確認できると思います。エイリアシングを避けるには、アタックとリリースには数ミリ秒程度は確保しておくと安全です。

adsr.attackTime = 0.01; //Attack:10ミリ秒
adsr.releaseTime = 0.01; //Release:10ミリ秒

ここまでのコード

差分のみ

コード全体

3.音階に対応させる

音階に対応した周波数で音を鳴らせるようにします。ここでは音階を $C_{4}$ のように $階名_{オクターブ数}$ の形で表記することにします。

MIDI の仕様では音階 $d$ と周波数 $f$ は次のように対応します1

$$
f=2^{(d-69)/12} 440 \text{ Hz}
$$

この式は $[0, 127]$ のノート番号 $d$ を取り、$C_{-1}$ から $G_9$ までの128音に対応した周波数に変換します。これは一般的な調律の標準音2である $A_4$ ($d=69$) の音を基準として、1オクターブごとに周波数が倍になるように定義したものです。

float NoteNumberToFrequency(int8_t d)
{
    return 440.0f * pow(2.0f, (d - 69) / 12.0f);
}

RenderWave 関数を和音を鳴らせるように書き変えてみます。引数で周波数を受け取る代わりに、ノート番号の Array を受け取るようにしました。

Wave RenderWave(uint32 seconds, double amplitude,
    const Array<int8_t>& noteNumbers, const ADSRConfig& adsr)
{
	const auto lengthOfSamples = seconds * Wave::DefaultSampleRate;

	Wave wave(lengthOfSamples);

	// 0サンプル目でノートオン
	EnvGenerator envelope;

	// 半分経過したところでノートオフ
	const auto noteOffSample = lengthOfSamples / 2;

	const float deltaT = 1.0f / Wave::DefaultSampleRate;
	float time = 0;

	for (uint32 i = 0; i < lengthOfSamples; ++i)
	{
		if (i == noteOffSample)
		{
			envelope.noteOff();
		}

		// 和音の各波形を加算合成する
		double w = 0;
		for (auto note : noteNumbers)
		{
			const auto freq = NoteNumberToFrequency(note);
			w += sin(Math::TwoPiF * freq * time)
				* amplitude * envelope.currentLevel();
		}

		wave[i].left = wave[i].right = static_cast<float>(w);
		time += deltaT;
		envelope.update(adsr, deltaT);
	}

	return wave;
}

これを使ってCメジャーの和音を鳴らしてみます。各ノート番号は $C_4$ = 60, $E_4$ = 64, $G_4$ = 67 です。Main関数の中身を以下のように書き変えます。

-       Audio audio(RenderWave(seconds, amplitude, frequency, adsr));
+       const Array<int8_t> noteNumbers =
+       {
+               60, // C_4
+               64, // E_4
+               67, // G_4
+       };
+
+       Audio audio(RenderWave(seconds, amplitude, noteNumbers, adsr));

ここまでのコード

差分のみ

コード全体

4.シンセサイザーを定義する

RenderWave が複雑になってきたので、波形を生成する機能をまとめた Synthesizer クラスを作りたいと思います。このクラスの役目は、入力されたノート番号を受け取って波形を生成して返すことです。

Synthesizer クラスに実装する機能

  • ノートオンを受け取って、入力されたノート情報を保持しておく
  • ノートオフを受け取って、リリース音を鳴らした後にノート情報を破棄する
  • 入力中のノート情報を参照し波形を生成して返す

ノートの入力状態

ノートの入力状態を管理する必要があるので、以下のように定義します。

struct NoteState
{
	EnvGenerator m_envelope;
};

std::multimap<int8_t, NoteState> m_noteState;

m_noteState はノート番号をキーにして、再生に必要なノート状態(ここではEnvGenerator)を返します。先ほどの例では EnvGenerator を1つだけ使って和音を鳴らしていました。しかし、実際のオンオフはノートごとに独立して起きるので、個別に管理する必要があります。

ノート状態を std::map でなく std::multimap で定義する理由は、ノートを離した後のリリース中に再度同じノートの入力があった時に、二重に再生できるようにするためです。この仕様自体は別に必要ないのですが、もし std::map で定義した場合、リリース中に再入力があった時には同じエンベロープを使うことになるので、音量レベルが飛ばないように遷移させる処理を挟む必要があります。ここではシンプルさを優先して std::multimap で管理することにします。

Synthesizer クラスを実装する

Synthesizer に実装する関数は以下の3つです。

class Synthesizer
{
public:

	// ノートオンを受け取って入力情報を記録する
	void noteOn(int8_t noteNumber)
	{
	}

	// ノートオフを受け取ってリリース状態に遷移させる
	void noteOff(int8_t noteNumber)
	{
	}

	// 入力中のノートの波形を1サンプル生成して返す
	WaveSample renderSample()
	{
	}

private:

	std::multimap<int8_t, NoteState> m_noteState;

	ADSRConfig m_adsr;
};

Synthesizer::noteOn()

noteOn() では m_noteState に要素を追加してノート番号の入力を記録します。

void noteOn(int8_t noteNumber)
{
	m_noteState.emplace(noteNumber, NoteState());
}

Synthesizer::noteOff()

noteOff() で行うことは、受け取ったノート番号を m_noteState から検索して、その要素のエンベロープをリリース状態に遷移させることで入力の終了を伝えます。1回の noteOff() で複数のノート状態を変更するとまずいので、一応最初に見つかった入力中のノートを終了させることにしています。

void noteOff(int8_t noteNumber)
{
    auto [beginIt, endIt] = m_noteState.equal_range(noteNumber);

    for (auto it = beginIt; it != endIt; ++it)
    {
        auto& envelope = it->second.m_envelope;

        // noteOnになっている最初の要素をnoteOffにする
        if (envelope.state() != EnvGenerator::State::Release)
        {
            envelope.noteOff();
            break;
        }
    }
}

Synthesizer::renderSample()

renderSample() 関数には波形生成ループの1回に相当する処理を実装します。

  • 入力中のノートのエンベロープを更新する
  • リリース処理が終わったノートの情報を削除する
  • 入力中のノートの波形を加算して計算する
  • 波形のサンプルを返す
WaveSample renderSample()
{
    // 1サンプルで進む時間 = サンプリング周波数の逆数
    const auto deltaT = 1.0 / Wave::DefaultSampleRate;

    // エンベロープの更新
    for (auto& [noteNumber, noteState] : m_noteState)
    {
        noteState.m_envelope.update(m_adsr, deltaT);
    }

    // リリースが終了したノートを削除する
    std::erase_if(m_noteState, [&](const auto& noteState) {
        return noteState.second.m_envelope.isReleased(m_adsr); });

    // 入力中の波形を加算して書き込む
    WaveSample sample(0, 0);
    for (auto& [noteNumber, noteState] : m_noteState)
    {
        const auto envLevel = noteState.m_envelope.currentLevel();
        const auto frequency = NoteNumberToFrequency(noteNumber);

        const auto w = static_cast<float>(
            sin(Math::TwoPiF * frequency * m_time) * envLevel);

        sample.left += w;
        sample.right += w;
    }

    // 時間を1サンプル分進める
    m_time += deltaT;

    return sample * m_amplitude;
}

時間を管理する m_time と音量パラメータの m_amplitude をメンバ変数に追加しました。

double m_amplitude = 0.2;
double m_time = 0;

先ほどの RenderWave 関数を Synthesizer クラスで書き直すと以下のようになりました。

Wave RenderWave(uint32 seconds, Synthesizer& synth)
{
	const auto lengthOfSamples = seconds * Wave::DefaultSampleRate;

	Wave wave(lengthOfSamples);

	// 半分経過したところでノートオフ
	const auto noteOffSample = lengthOfSamples / 2;

	synth.noteOn(60); // C_4
	synth.noteOn(64); // E_4
	synth.noteOn(67); // G_4

	for (uint32 i = 0; i < lengthOfSamples; ++i)
	{
		if (i == noteOffSample)
		{
			synth.noteOff(60);
			synth.noteOff(64);
			synth.noteOff(67);
		}

		wave[i] = synth.renderSample();
	}

	return wave;
}
void Main()
{
	uint32 seconds = 3;

	Synthesizer synth;

	Audio audio(RenderWave(seconds, synth));
	audio.play();

	while (System::Update())
	{
		Vec2 pos(20, 20 - SliderHeight);

		synth.updateGUI(pos);

		if (SimpleGUI::Button(U"波形を再生成", Vec2{ pos.x, pos.y += SliderHeight }))
		{
			synth.clear();

			audio = Audio(RenderWave(seconds, synth));
			audio.play();
		}
	}
}

ここまでのコード

差分のみ

コード全体

5.譜面を再生する

実際に SMF(Standard MIDI File) から譜面を読み込んで再生できるようにします。SMF の読み込み処理は煩雑なので省略します。このサイトが詳しくてわかりやすかったです。

ここではサンプルリポジトリに上げた SoundTools.hppLoadMidi() 関数からファイルを読み込んだ前提で、その後の MIDI データの解釈について説明をします。

SMF のデータ構造

SMF は複数のトラックから構成されており、それぞれのトラックは譜面を MIDI イベントのリストの形式で待っています。トラックの分け方についての規定はありませんが、楽器ごとだったり高音と低音で分けたりするのが一般的です。また、最初のトラックは慣例的にコンダクタートラック(テンポトラック)と呼ばれ、譜面データの定義はせずに拍子とテンポの設定だけを行うものとして扱われます3

SMF のバイナリの構造を midi-dump4 で可視化した例:
smf_format.png

時間の単位

MIDI の時間単位は delta_time という全体設定で定義されます。これは一拍(四分音符)の分解能を示す値で5、例えば delta_time = 480 の場合、四分音符の長さは 480 tick、十六分音符の長さは 120 tick となります。

各 MIDI イベントは、イベントの発生時刻を tick の値で持っています。したがって、アプリ側で tick を進めていって発生時刻を経過した MIDI イベントを実行することで、譜面の演奏をすることができます。

1秒間に何 tick 進めればいいかは、その時点での BPM の値に依存します。BPM はセットテンポというメタイベントによって動的に変化します。

MIDI イベント

譜面データは MIDI イベント6として定義されています。

  • ノートオン
  • ノートオフ
  • ポリフォニックキープレッシャー
  • コントロールチェンジ
  • プログラムチェンジ
  • チャンネルプレッシャー
  • ピッチベンド

MIDI イベントは全部で7種類あります。ここでの実装にとりあえず必要なのはノートオンとノートオフの二つだけです。以下に主要なイベントについて説明をします。

ノートオン

キー入力が開始したことを示すイベントです。キーのノート番号と入力の強さを示すベロシティーを待っています。

struct NoteOnEvent
{
	uint8 channel;
	uint8 note_number;
	uint8 velocity;
};

ノートオフ

キー入力が終了したことを示すイベントです。キーのノート番号を持っています。

struct NoteOffEvent
{
	uint8 channel;
	uint8 note_number;
};

コントロールチェンジ

コントロールチェンジはいろいろなパラメータを変更するためのイベントです。この中でさらにたくさんの種類に分けられ7、変更するパラメータごとにコントロール番号が振られています。この中でCC10:パンポットCC11:エクスプレッションはよく使われるので重要です。

struct ControlChangeEvent
{
	uint8 channel;
	uint8 type;
	uint8 value;
};

CC10:パンポット :左右のパン振りを変更する

CC11:エクスプレッション :音量を変更する

プログラムチェンジ

プログラムチェンジはトラックの音源を設定するイベントです。原則として各トラックの先頭で一回だけ呼ばれます。

struct ProgramChangeEvent
{
	uint8 channel;
	uint8 type;
};

ピッチベンド

音の高さを変えるイベントです。指定したチャンネルについて、ノート番号から計算された周波数を相対値で上下に動かします。

struct PitchBendEvent
{
	uint8 channel;
	uint16 value;
};

プログラムとチャンネルとトラック

上述の通り、全ての MIDI イベントは設定先として チャンネル という値をパラメータに持っています。しかし、トラックはすでに楽器ごとに分かれていて、トラックごとに MIDI イベントを持っているはずなので、設定先はトラックに対して紐付けるのが最も自然に思えます。

ここで、チャンネルという概念がややこしいので一旦 MIDI について整理したいと思います。MIDI はもともと楽器同士の通信規格として定義されたもので、その仕様の上に MIDI データを記録した SMF というファイルフォーマットが定義されました。そして、トラックは SMF にしかないものなので、音源とトラックを結び付けるにはチャンネルを介して情報を受け渡す必要があります。

smf_spec.png

それぞれの要素について以下で簡単に説明をします。

プログラム

プログラムは音源リストの定義です。音源リストが楽器によって完全に独立だと、楽器ごとに送る信号を変えないといけなくなります。これを避けるために、128個の音源の共通仕様として GM(General MIDI) という標準規格が定義されています。楽器メーカーが独自に定義した GS や XG といったプログラムも存在しますが、いずれも GM 規格をもとに拡張したものです。

チャンネル

チャンネルとは、楽器同士が信号をやり取りする際に使う通信路の番号のことです。使用する音源のそれぞれに1~16までの値を設定しておき、そのチャンネルに向けて MIDI 信号を送ること楽器ごとの演奏を行います。音源とチャンネルの結びつきはプログラムチェンジイベントで設定します。また、チャンネル番号=10はパーカッション用に予め確保されています。

トラック

演奏データを MIDI イベントの形式で定義したものです。各トラックはプログラムチェンジイベントを発行することで、そのトラックが使用するチャンネルと、チャンネルからプログラムへの対応を同時に設定します。

MIDI 譜面を波形に書き込む関数を作る

実際に譜面データをサイン波でWaveに書き込む関数を作ってみます。

まず、MIDI における時間は tick で管理されているので、波形のサンプル位置から tick への変換を行う必要があります。これは少し面倒なので midiData.secondsToTicks 関数に定義しました。

const auto currentTime = 1.0 * i / wave.sampleRate();
const auto currentTick = midiData.secondsToTicks(currentTime);

tick はサンプリング周波数よりもずっと粗い単位なので、毎サンプル MIDI イベントが発生することはありません。現在のサンプルと次のサンプルの tick を比較して、進んでいた時だけ MIDI イベントをチェックするようにすれば十分です。

if (currentTick != nextTick)
{
    // MIDIイベントの取得処理
}

また、チャンネル番号=10はパーカッション用という説明をしましたが、これはトラックについても同様です。チャンネル番号=10を使用するトラックは パーカッショントラック として扱われます。このトラックにはドラムなど打楽器の譜面が入っており、サイン波で再生すると大変なことになってしまうので、今回はスキップします。

if (track.isPercussionTrack())
{
    continue;
}

MidiData クラスの各トラックについて getMIDIEvent() 関数を呼ぶことで、トラックの MIDI イベントを取得できるようになっています。これを実行すると、引数に指定した currentTick 以上 nextTick 未満の範囲にあるイベントが返ります。

const auto noteOnEvents = track.getMIDIEvent<NoteOnEvent>(currentTick, nextTick);
const auto noteOffEvents = track.getMIDIEvent<NoteOffEvent>(currentTick, nextTick);

以上の処理とまとめて、MIDI 譜面を Wave に書き込む関数を以下のように定義できます。

Wave RenderWave(Synthesizer& synth, const MidiData& midiData)
{
	const auto lengthOfSamples = static_cast<int64>(ceil(midiData.lengthOfTime() * Wave::DefaultSampleRate));

	Wave wave(lengthOfSamples);

	for (int64 i = 0; i < lengthOfSamples; ++i)
	{
		const auto currentTime = 1.0 * i / wave.sampleRate();
		const auto nextTime = 1.0 * (i + 1) / wave.sampleRate();

		const auto currentTick = midiData.secondsToTicks(currentTime);
		const auto nextTick = midiData.secondsToTicks(nextTime);

		// tick が進んだら MIDI イベントの処理を更新する
		if (currentTick != nextTick)
		{
			for (const auto& track : midiData.tracks())
			{
				if (track.isPercussionTrack())
				{
					continue;
				}

				// 発生したノートオンイベントをシンセに登録
				const auto noteOnEvents = track.getMIDIEvent<NoteOnEvent>(currentTick, nextTick);
				for (auto& [tick, noteOn] : noteOnEvents)
				{
					synth.noteOn(noteOn.note_number, noteOn.velocity);
				}

				// 発生したノートオフイベントをシンセに登録
				const auto noteOffEvents = track.getMIDIEvent<NoteOffEvent>(currentTick, nextTick);
				for (auto& [tick, noteOff] : noteOffEvents)
				{
					synth.noteOff(noteOff.note_number);
				}
			}
		}

		// シンセを1サンプル更新して波形を書き込む
		wave[i] = synth.renderSample();
	}

	return wave;
}

MIDI ファイルを読み込んで再生するプログラムのサンプルです。

void Main()
{
	auto midiDataOpt = LoadMidi(U"short_loop.mid");
	if (!midiDataOpt)
	{
		// ファイルが見つからない or 読み込みエラー
		return;
	}

	const MidiData& midiData = midiDataOpt.value();

	Synthesizer synth;

	Audio audio(RenderWave(synth, midiData));
	audio.play();

	while (System::Update())
	{
		Vec2 pos(20, 20 - SliderHeight);

		synth.updateGUI(pos);

		if (SimpleGUI::Button(U"波形を再生成", Vec2{ pos.x, pos.y += SliderHeight }))
		{
			audio.stop();
			synth.clear();

			audio = Audio(RenderWave(synth, midiData));
			audio.play();
		}
	}
}

ここまでのコード

差分のみ

コード全体

次回

  1. https://en.wikipedia.org/wiki/MIDI_tuning_standard

  2. https://ja.m.wikipedia.org/wiki/A440

  3. https://www.g200kg.com/jp/docs/dic/tempotrack.html

  4. https://g200kg.github.io/midi-dump/

  5. delta_time は分解能のほかに秒数で定義することも一応可能ですが、実際に使われているところを見たことはありません

  6. MIDIイベントというのはSMF固有の呼び方で、MIDI規格ではチャンネルボイスメッセージと呼ばれます

  7. https://ja.m.wikipedia.org/wiki/コントロールチェンジ

13
11
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
13
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?