LoginSignup
5
2

More than 1 year has passed since last update.

ソフトウェアシンセサイザーを作る その3:波形を合成して音を作る

Posted at

前回

前回の記事では、ウェーブテーブルを使った基本波形の実装を行いました。最後に書いたコードの続きから作っていきます。

概要

今回は波形の合成や変調を行う部分を実装します。また、実装した機能を使ってサウンドをいくつか作ってみます。

準備:可視化機能を実装する

まず、前回の記事で使った可視化機能を再び使えるようにするためのコードを追加します。前回はAudioをそのままAudioVisualizerに渡していましたが、ストリーム再生を行う場合は内部で波形を取得できないので、自分で再生中の波形データをAudioVisualizerに渡す必要があります。

AudioRendererに再生用のバッファと再生位置を取得するメンバ関数を追加します。

class AudioRenderer
class AudioRenderer : public IAudioStream
{
	...
	
+	const Array<WaveSample>& buffer() const
+	{
+		return m_buffer;
+	}
+
+	// audio.play()から経過した現在のサンプル位置
+	size_t bufferReadPos() const
+	{
+		return m_bufferReadPos;
+	}
+
+	// MIDI再生開始から経過した現在のサンプル位置
+	size_t playingMIDIPos() const
+	{
+		return m_readMIDIPos - (m_bufferWritePos - m_bufferReadPos);
+	}
};

audioStreamから取得した再生波形をvisualizeBufferに渡して可視化を行います。

void Main()
void Main()
{
+	AudioVisualizer visualizer;
+	visualizer.setSplRange(-60, -30);
+	visualizer.setWindowType(AudioVisualizer::Hamming);
+	visualizer.setDrawScore(NoteNumber::C_2, NoteNumber::B_7);
+	visualizer.setDrawArea(Scene::Rect());

	...

	while (System::Update())
	{
+		auto& visualizeBuffer = visualizer.inputWave();
+		visualizeBuffer.fill(0);
+
+		const auto& streamBuffer = audioStream->buffer();
+		const auto readStartPos = audioStream->bufferReadPos();
+
+		const size_t fftInputSize = Min(visualizeBuffer.size(), streamBuffer.size());
+
+		for (size_t i = 0; i < fftInputSize; ++i)
+		{
+			const size_t inputIndex = (readStartPos + i) % streamBuffer.size();
+			const auto& sample = streamBuffer[inputIndex];
+			visualizeBuffer[i] = (sample.left + sample.right) * 0.5f;
+		}
+
+		visualizer.updateFFT(fftInputSize);
+		
+		const auto currentTime = 1.0 * audioStream->playingMIDIPos() / SamplingFreq;
+		visualizer.drawScore(midiDataOpt.value(), currentTime);

		...

20230107-135401-307.png

ここまでのコード

差分のみ

プログラム全体

1.パンとピッチシフト

音を左右に振るパン機能と、周波数を上下に動かすピッチシフト機能を実装します。

以降では Synthesizer クラスにパラメータを追加しながら実装を行っていきます。

追加するパラメータ

pan : [0.0, 1.0]

0.0 で左、0.5 で中央、1.0 で右から音が聞こえるようにします。向きによって音量が変わらないように、三角関数で正規化された係数を求めています。

WaveSample Synthesizer::renderSample()
+sample.left *= static_cast<float>(cos(Math::HalfPi * m_pan));
+sample.right *= static_cast<float>(sin(Math::HalfPi * m_pan));

return sample * static_cast<float>(m_amplitude);

pitchShift : [-24.0, 24.0]

周波数を倍にすると1オクターブ上がり、半分にすると1オクターブ下がります。パラメータは整数で半音ずつシフトできると使いやすいので、2の12乗根を pitchShift 乗した値を周波数の係数とします。

WaveSample Synthesizer::renderSample()
+const auto pitch = pow(2.0, m_pitchShift / 12.0);

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

スライダーで動かすときは Ctrl キーなどで半音単位にスナップさせると使いやすいです。

void Synthesizer::updateGUI(Vec2& pos)
if (SimpleGUI::Slider(U"pitchShift : {:.2f}"_fmt(m_pitchShift), m_pitchShift, -24.0, 24.0, Vec2{ pos.x, pos.y += SliderHeight }, LabelWidth, SliderWidth)
     && KeyControl.pressed())
{
    m_pitchShift = Math::Round(m_pitchShift); // 四捨五入して最も近い半音に丸める
}

ここまでのコード

差分のみ

プログラム全体

2.ユニゾン

ユニゾンは波形の周波数を少しずつずらしながら重ねることで、音にコーラスのような厚みを持たせる機能です。

ユニゾン波形の周波数スペクトル↓
detune_spectrum.gif

追加するパラメータ

unisonCount : [1, 16]

重ねる波形の本数です。多いほど音はきれいになりますが、この数だけオシレータを呼び出すことになるため処理負荷には注意が必要です。

detune : [0.0, 1.0]

周波数のシフト量です。detuneで指定した最大幅をunisonCountで分割して、各ユニゾン波形のピッチシフト量を計算します。ずらし過ぎるとただの不協和音になるので、半音ずれる量を最大値にしておきます。

detune.png

void Synthesizer::updateUnisonParam()
static const double Semitone = pow(2.0, 1.0 / 12.0) - 1.0;

for (int d = 0; d < m_unisonCount; ++d)
{
	// 各波形の位置を[-1, 1]で計算する
	const auto detunePos = Math::Lerp(-1.0, 1.0, 1.0 * d / (m_unisonCount - 1));

    // 周波数を最大で Semitone * m_detune だけシフトする
	m_detunePitch[d] = static_cast<float>(1.0 + HalfTone * m_detune * detunePos);

	...
}

spread : [0.0, 1.0]

ユニゾン波形ごとに音を左右に振り分けるパラメータです。0でモノラル、1で各波形を真左から真右まで等間隔に配置した状態になるようにします。

void Synthesizer::updateUnisonParam()
for (int d = 0; d < m_unisonCount; ++d)
{
	...

    // Math::QuarterPi が中央
	const auto unisonAngle = Math::QuarterPi * (1.0 + detunePos * m_spread);
	m_unisonPan[d] = Float2(cos(unisonAngle), sin(unisonAngle));
}

spread_.png

ユニゾンの実装

ノートごとに1つ持たせていた位相変数をユニゾンの数だけ持たせるようにします。動的に数を変えるとややこしいので、常に最大数を確保しています。

struct NoteState
+static constexpr uint32 MaxUnisonSize = 16;

struct NoteState
{
-	double m_phase = 0;
+	// ユニゾン波形ごとに進む周波数が異なるので、位相を個別に管理する
+	std::array<double, MaxUnisonSize> m_phase = {};
	float m_velocity = 1.f;
	EnvGenerator m_envelope;
};

Synthesizerクラスの差分

renderSample()では、事前に計算した m_detunePitchm_unisonPan を使って波形にデチューンとパンを適用します。

class Synthesizer
class Synthesizer
{   
	Synthesizer()
	{
+		m_detunePitch.fill(1);
+		m_unisonPan.fill(Float2::One().normalize());
	}

	WaveSample renderSample()
	{
		const auto deltaT = 1.0 / SamplingFreq;
		...

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

-			const auto osc = OscWaveTables[m_oscIndex].get(noteState.m_phase, frequency);
-			noteState.m_phase += deltaT * frequency * 2_pi;
-			if (Math::TwoPi < noteState.m_phase)
-			{
-				noteState.m_phase -= Math::TwoPi;
-			}
-
-			const auto w = static_cast<float>(osc * envLevel);
-			sample.left += w;
-			sample.right += w;
+			for (int d = 0; d < m_unisonCount; ++d)
+			{
+				const auto detuneFrequency = frequency * m_detunePitch[d];
+				auto& phase = noteState.m_phase[d];
+
+				const auto osc = OscWaveTables[m_oscIndex].get(phase, detuneFrequency);
+				phase += deltaT * detuneFrequency * Math::TwoPiF;
+				if (Math::TwoPi < phase)
+				{
+					phase -= Math::TwoPi;
+				}
+
+				const auto w = static_cast<float>(osc * envLevel);
+				sample.left += w * m_unisonPan[d].x;
+				sample.right += w * m_unisonPan[d].y;
+			}
		}

		sample.left *= static_cast<float>(cos(Math::HalfPi * m_pan));
		sample.right *= static_cast<float>(sin(Math::HalfPi * m_pan));

-		return sample * static_cast<float>(m_amplitude);
+		// ユニゾンを増やしただけ音が大きくなってしまうので少し下げる(sqrtは適当)
+		return sample * static_cast<float>(m_amplitude / sqrt(m_unisonCount));
	}
	
+	void updateUnisonParam()
+	{
+		// ユニゾンなし
+		if (m_unisonCount == 1)
+		{
+			m_detunePitch[0] = 1;
+			m_unisonPan[0] = Float2::One().normalize();
+			return;
+		}
+	
+		// ユニゾンあり
+		for (int d = 0; d < m_unisonCount; ++d)
+		{
+			// 各波形の位置を[-1, 1]で計算する
+			const auto detunePos = Math::Lerp(-1.0, 1.0, 1.0 * d / (m_unisonCount - 1));
+	
+			// 現在の周波数から最大で HalfTone * m_detune だけピッチシフトする
+			m_detunePitch[d] = static_cast<float>(1.0 + HalfTone * m_detune * detunePos);
+	
+			// Math::QuarterPi が中央
+			const auto unisonAngle = Math::QuarterPi * (1.0 + m_spread * detunePos);
+			m_unisonPan[d] = Float2(cos(unisonAngle), sin(unisonAngle));
+		}
+	}

	...
+	std::array<float, MaxUnisonSize> m_detunePitch;
+	std::array<Float2, MaxUnisonSize> m_unisonPan;
};

ユニゾンパラメータを更新したときにupdateUnisonParam()を呼んでm_detunePitchm_unisonPanを再計算します。

void Synthesizer::updateGUI
void Synthesizer::updateGUI(Vec2& pos)
{
	...

+	bool unisonUpdated = false;
+	unisonUpdated = SliderInt(U"unisonCount : {}"_fmt(m_unisonCount), m_unisonCount, 1, 16, Vec2{ pos.x, pos.y += SliderHeight }, LabelWidth, SliderWidth) || unisonUpdated;
+	unisonUpdated = SimpleGUI::Slider(U"detune : {:.2f}"_fmt(m_detune), m_detune, 0.0, 1.0, Vec2{ pos.x, pos.y += SliderHeight }, LabelWidth, SliderWidth) || unisonUpdated;
+	unisonUpdated = SimpleGUI::Slider(U"spread : {:.2f}"_fmt(m_spread), m_spread, 0.0, 1.0, Vec2{ pos.x, pos.y += SliderHeight }, LabelWidth, SliderWidth) || unisonUpdated;
+
+	if (unisonUpdated)
+	{
+		updateUnisonParam();
+	}
}

うなりを軽減する

近い周波数の波形を重ねると、波形間の位相が少しずつずれていくため、山同士が重なって音が大きくなるタイミングと、山と谷が打ち消しあって音が小さくなるタイミングが繰り返し発生します(これをうなりと言います)。

うなりを完全に消すことはできませんが、初期位相をランダムにセットすることで多少軽減することができます。

初期位相のみ異なる値に設定したユニゾン波形の比較↓
initial_phase.png

うなりの対策として、unisonCountを増やす、spreadを大きめにかける、なども有効です(参考:https://sound.stackexchange.com/questions/37625/how-to-use-unison-without-voice-beating-phasing

NoteStateクラスに初期位相のランダムリセットを追加します。

struct NoteState
struct NoteState
{
+	NoteState()
+	{
+		for (auto& initialPhase : m_phase)
+		{
+			// 位相をランダムに初期化する
+			initialPhase = Random(0.0, 2_pi);
+		}
+	}

	// ユニゾン波形ごとに進む周波数が異なるので、位相を個別に管理する
	std::array<double, MaxUnisonSize> m_phase = {};
	float m_velocity = 1.f;
	EnvGenerator m_envelope;
};

動作確認

動作確認用にユニゾン波形の周波数スペクトルをデバッグ描画するプログラム例を以下に示します。

void Main()
void Main()
{
 	Window::Resize(1600, 900);
 
-	auto midiDataOpt = LoadMidi(U"example/midi/test.mid");
+	auto midiDataOpt = LoadMidi(U"C5_B8.mid");

	...

-	visualizer.setDrawScore(NoteNumber::C_2, NoteNumber::B_7);
-	visualizer.setDrawArea(Scene::Rect());
+	visualizer.setFreqRange(300, 10000);
+	visualizer.setDrawArea(Scene::Rect().stretched(-50));
 
 	std::shared_ptr<AudioRenderer> audioStream = std::make_shared<AudioRenderer>();
 	audioStream->setMidiData(midiDataOpt.value());
 
 	auto& synth = audioStream->synth();
-	synth.setOscIndex(static_cast<int>(WaveForm::Sin));
+	synth.setOscIndex(static_cast<int>(WaveForm::Saw));
+	synth.setUnisonCount(4);
+	synth.setDetune(0.5);
+	synth.setSpread(0.0);
 
	...
 			visualizer.updateFFT(fftInputSize);
 
-			const auto currentTime = 1.0 * audioStream->playingMIDIPos() / SamplingFreq;
-			visualizer.drawScore(midiDataOpt.value(), currentTime);
+			visualizer.draw();
 		}
	...

ここまでのコード

差分のみ

プログラム全体

サウンド作例:Supersaw

Supersaw はデチューンされたノコギリ波を重ねたサウンド(と説明されることが多い)です。これはユニゾン機能だけで作れるので、以下に設定の例を示します。

パラメータの設定値

  • oscillator : 0
  • unisonCount : 16
  • detune : 0.35
  • spread : 1.0
  • attack : 0.01
  • decay : 0.08
  • sustain : 0.65
  • release : 0.50

3.モノフォニックとポリフォニック

同時に1つの音しか出せない楽器をモノフォニック、複数の音を出せる楽器をポリフォニックと呼びます。今まで実装してきたものは和音を鳴らせるのでポリフォニックシンセに当たります。後述するレガートやグライド機能を使うために、モノフォニック化するモードも実装します。

mono_poly.png

追加するパラメータ

mono : true | false

モノフォニックモードを有効化するフラグです。モノフォニックの時はNoteStateの数を1つまでに制限します。

legato : true | false

キーが押されている途中に別のキーが押された時のエンベロープのリセット方法を指定するフラグです。オンの場合は音を止めずに滑らかに次の音に移行できるようにします。

true の場合: エンベロープを Sustain ステートにセット
false の場合: エンベロープを Attack ステートにセット

モノフォニックモードの実装

Synthesizerクラスのノートオンイベントに、モノフォニック時の処理を追加します。

void Synthesizer::noteOn()
void Synthesizer::noteOn(int8_t noteNumber, int8_t velocity)
{
-	NoteState noteState;
-	noteState.m_velocity = velocity / 127.0f;
-	m_noteState.emplace(noteNumber, noteState);
+	if (!m_mono || m_noteState.empty())
+	{
+		NoteState noteState;
+		noteState.m_velocity = velocity / 127.0f;
+		m_noteState.emplace(noteNumber, noteState);
+	}
+	else
+	{
+		auto [key, oldState] = *m_noteState.begin();
+
+		// ノート番号が同じとは限らないので一回消して作り直す
+		m_noteState.clear();
+
+		NoteState noteState = oldState;
+		noteState.m_velocity = velocity / 127.0f;
+		noteState.m_envelope.reset(m_legato ? EnvGenerator::State::Sustain : EnvGenerator::State::Attack);
+		m_noteState.emplace(noteNumber, noteState);
+	}
}

エンベロープの挙動を修正する

エンベロープのリセット処理を追加しましたが、EnvGeneratorは動いている途中での状態変化に対応していませんでした。どの時点で状態をリセットしても、値が飛ばないように連続的に遷移させる処理を追加します。

class EnvGenerator
class EnvGenerator
{
	void noteOff()
	{
		if (m_state != State::Release)
		{
+			m_prevStateLevel = m_currentLevel;
			m_elapsed = 0;
			m_state = State::Release;
		}
	}

	void reset(State state)
	{
+		m_prevStateLevel = m_currentLevel;
		m_elapsed = 0;
		m_state = state;
	}

	...

	double m_currentLevel = 0; // 現在のレベル [0, 1]
+	double m_prevStateLevel = 0; // ステート変更前のレベル [0, 1]
};

状態変更時にm_prevStateLevelに現在のレベルを保存して、各ステートでの補間の開始をこの値にします。

Attack の修正

void EnvGenerator::update()
case State::Attack: // 0.0 から 1.0 まで attackTime かけて増幅する
	if (m_elapsed < adsr.attackTime)
	{
-		m_currentLevel = m_elapsed / adsr.attackTime;
+		m_currentLevel = Math::Lerp(m_prevStateLevel, 1.0, m_elapsed / adsr.attackTime);
		break;
	}
+	m_prevStateLevel = m_currentLevel;
	m_elapsed -= adsr.attackTime;
	m_state = State::Decay;
	[[fallthrough]]; // Decay処理にそのまま続く

Decay の修正

void EnvGenerator::update()
case State::Decay: // 1.0 から sustainLevel まで decayTime かけて減衰する
	if (m_elapsed < adsr.decayTime)
	{
-		m_currentLevel = Math::Lerp(1.0, adsr.sustainLevel, m_elapsed / adsr.decayTime);
+		m_currentLevel = Math::Lerp(m_prevStateLevel, adsr.sustainLevel, m_elapsed / adsr.decayTime);
		break;
	}
+	m_prevStateLevel = m_currentLevel;
	m_elapsed -= adsr.decayTime;
	m_state = State::Sustain;
	[[fallthrough]]; // Sustain処理にそのまま続く

Sustain の修正

他のステートから Sustain に飛んだ時も不連続にならないように補間する処理を追加します。補間にかかる時間を表す sustainResetTime を追加しました。

void EnvGenerator::update()
case State::Sustain: // ノートオンの間 sustainLevel を維持する
-	m_currentLevel = adsr.sustainLevel;
+	if (m_elapsed < adsr.sustainResetTime)
+	{
+		m_currentLevel = Math::Lerp(m_prevStateLevel, adsr.sustainLevel, m_elapsed / adsr.sustainResetTime);
+	}
+	else
+	{
+		m_currentLevel = adsr.sustainLevel;
+	}
	break;
struct ADSRConfig
struct ADSRConfig
{
	double attackTime = 0.01;
	double decayTime = 0.01;
	double sustainLevel = 0.6;
+	double sustainResetTime = 0.05;
	double releaseTime = 0.4;
};

Release の修正

void EnvGenerator::update()
case State::Release: // sustainLevel から 0.0 まで releaseTime かけて減衰する
	m_currentLevel = m_elapsed < adsr.releaseTime
-		? Math::Lerp(adsr.sustainLevel, 0.0, m_elapsed / adsr.releaseTime)
+		? Math::Lerp(m_prevStateLevel, 0.0, m_elapsed / adsr.releaseTime)
		: 0.0;
	break;

動作確認

monolegato の動作確認用プログラムです。legato がオフだと各ノート開始時のアタック音が聞こえて、オフだと常に一定の音量で鳴り続けるのが確認できると思います。

void Main()
void Main()
{
 	Window::Resize(1600, 900);
 
-	auto midiDataOpt = LoadMidi(U"C5_B8.mid");
+	auto midiDataOpt = LoadMidi(U"glide_test.mid");
 	
	...

 	auto& synth = audioStream->synth();
-	synth.setOscIndex(static_cast<int>(WaveForm::Saw));
-	synth.setUnisonCount(4);
-	synth.setDetune(0.5);
-	synth.setSpread(0.0);
+	synth.setOscIndex(static_cast<int>(WaveForm::Sin));
+	synth.setMono(true);
+	synth.setLegato(false);
+	synth.setAmplitude(0.4);
 
 	auto& adsr = synth.adsr();
 	adsr.attackTime = 0.01;
-	adsr.decayTime = 0.0;
-	adsr.sustainLevel = 1.0;
+	adsr.decayTime = 0.1;
+	adsr.sustainLevel = 0.2;
 	adsr.releaseTime = 0.01;

	...

未対応のケース

モノフォニック時の動作はシンプルに後から来たノートオンで常に上書きする仕様にしました。この実装はノートが互いに重なる場合(下図左)には期待通りに動作しますが、片方が完全に隠れる場合(下図右)は ノートが存在するのに鳴らない 区間が生じるので注意してください。
mono.png

ここまでのコード

差分のみ

プログラム全体

4.グライド(ポルタメント)

連続したキー入力があったときに、キーの間で周波数を滑らかに補間する機能です。

20230106-194954-406.png

追加するパラメータ

glide : true | false

グライド機能を有効化するフラグです。

glideTime : [0.001, 0.5]

ノートオンを受け取った後、現在の周波数からglideTime秒かけて指定されたノートの周波数まで遷移させます。ただし、遷移方法はデフォルトで音階が直線的に動くようにしたいので 対数軸上での線形補間 で実装します。

線形補間:
$$
f = a + (b - a) t
$$

対数軸上での補間:
$$
f = a \times (b / a)^{t}
$$

グライドの実装

グライド機能に使うメンバ変数をSynthesizerクラスに追加します。

class Synthesizer
class Synthesizer
{
	...	

+	// パラメータ
+	bool m_glide = false;
+	double m_glideTime = 0.001;

+	// 計算用
+	double m_currentFreq = 440; //現在の周波数を常に保存しておく
+	double m_startGlideFreq = 440; // グライド開始時の周波数
+	double m_glideElapsed = 0.0; // グライド開始から経過した秒数
};

ノートオンを受けた時に、現在の周波数をグライド開始時の周波数として保存し、遷移に使う時間変数をリセットします。

void Synthesizer::noteOn()
void noteOn(int8_t noteNumber, int8_t velocity)
{
	...

+	if (m_mono && m_glide)
+	{
+		m_startGlideFreq = m_currentFreq;
+		m_glideElapsed = 0;
+	}
}

波形生成時にpow()を使って周波数を補間してm_currentFreqを更新します。

WaveSample Synthesizer::renderSample()
for (auto& [noteNumber, noteState] : m_noteState)
{
+	const auto targetFreq = NoteNumberToFrequency(noteNumber);
+
+	if (m_mono && m_glide)
+	{
+		const double targetScale = targetFreq / m_startGlideFreq;
+		const double rate = Saturate(m_glideElapsed / m_glideTime);
+		m_currentFreq = m_startGlideFreq * pow(targetScale, rate);
+		m_glideElapsed += deltaT;
+	}
+	else
+	{
+		m_currentFreq = targetFreq;
+	}

	const auto envLevel = noteState.m_envelope.currentLevel() * noteState.m_velocity;
-	const auto frequency = NoteNumberToFrequency(noteNumber) * pitch;
+	const auto frequency = m_currentFreq * pitch;
	
	...

rateの値にイージングを掛けることで、グライドに直線以外のカーブを設定することも可能です。

m_currentFreq = m_startGlideFreq * pow(targetScale, EaseInOutExpo(rate));

EaseInOutExpo.png

m_currentFreq = m_startGlideFreq * pow(targetScale, EaseInCubic(rate));

EaseInCubic.png

ここまでのコード

差分のみ

プログラム全体

サウンド作例:Evansのリード音

元の音がSupersawっぽい波形+グライドを多用している音ということで、これにできるだけ似せようとしてみました。

パラメータの設定値

  • oscillator : 0
  • unisonCount : 16
  • detune : 0.4
  • spread : 0.5
  • attack : 0.01
  • decay : 0.03
  • sustain : 0.85
  • release : 0.14
  • mono : true
  • legato : false
  • glide : true
  • glideTime : 0.02

5.LFO による変調

LFO(Low Frequency Oscillator)は、波形の出力値を他のパラメータに同期させることができるオシレータ機能です。LFO のように、時間変化する入力値を使って生成波形のパラメータを変化させることを 変調 と呼びます。

lfo_demo.gif

変調機能を実装するために新たに二つのクラスを定義します。

  • オシレータ関数を再生するLFOクラス
  • パラメータの値を変化させるModParameterクラス

LFO クラス

LFO クラスでは波形の位相を管理して、現在の LFO の出力値を保持しておきます。必要最小限のパラメータとして以下の項目を設定できるように実装します。

  • 任意のカーブ設定
  • 再生速度の設定
  • ループする/しない

これ以外には、LFO の位相をリセットするトリガー条件の設定や、周期を四分音符や八分音符など曲のテンポに合わせる機能も付いていることが多いです。

カーブの設定

使用するカーブは外から設定できるように std::function<double(double)> 型のパラメータとして持たせておきます。関数の入出力範囲は通常のオシレータと同様に、入力範囲:$[0,2 \pi]$、出力範囲:$[-1,1]$ であるものとして扱います。

関数によっては計算コストが高い可能性があるので、問題になる場合は事前にウェーブテーブルに焼いた方が良いかもしれません。

LFO クラスの実装

class LFO
class LFO
{
public:

	// 位相をリセットする
	void reset()
	{
		m_phase = 0;
	}

	// 位相を進めて現在の出力値を m_currentLevel に保存する
	void update(double dt)
	{
		if (m_lfoFunction)
		{
			// 音符の長さで周期を設定する場合は、ここでBPMを受け取って時間に変換する
			const double cycleTime = m_seconds;
			const double deltaPhase = Math::TwoPi * dt / cycleTime;

			m_currentLevel = m_lfoFunction(m_phase);
			m_phase += deltaPhase;

			if (Math::TwoPi < m_phase)
			{
				if (m_loop)
				{
					m_phase -= Math::TwoPi;
				}
				else
				{
					m_phase = Math::TwoPi;
				}
			}
		}
	}

	// 現在の入力値: [0, 2pi]
	double phase() const
	{
		return m_phase;
	}

	// 現在の出力値: [-1.0, 1.0]
	double currentLevel() const
	{
		return m_currentLevel;
	}

	// 周期を設定する
	void setSeconds(double seconds)
	{
		m_seconds = seconds;
	}

	// カーブを設定する
	void setFunction(std::function<double(double)> func)
	{
		m_lfoFunction = func;
	}

	bool isLoop() const
	{
		return m_loop;
	}

	// ループを有効にする
	void setLoop(bool isLoop)
	{
		m_loop = isLoop;
	}

private:

	double m_seconds = 1;
	bool m_loop = true;
	std::function<double(double)> m_lfoFunction;

	double m_phase = 0;
	double m_currentLevel = 0;
};

ModParameter クラス

ModParameter クラスは LFO の出力値を受け取り、事前に設定した範囲へ Lerp した値でパラメータを書き変えます。

LFO は同時に複数扱うことが多いので、対応する LFO のインデックスを持たせています。

これまで double 型で実装していた変調対象のパラメータを ModParameter 型に置き換えるので、LFO を使わないときは通常のパラメータとして振る舞えるように double value をそのまま公開しています。LFO を使うときは、値を参照する前に明示的に fetch を呼ぶことで value を現在の値で更新するようにしました。

class ModParameter
class ModParameter
{
public:

	ModParameter(double value) : value(value) {}

	// LFO を受け取ってパラメータ値を書き変える(書き換わったら true を返す)
	bool fetch(const Array<LFO>& lfoTable)
	{
		if (m_modIndex)
		{
			const double x = lfoTable[m_modIndex.value()].currentLevel();
			const double newValue = Math::Lerp(m_low, m_high, x * 0.5 + 0.5);
			if (value != newValue)
			{
				value = newValue;
				return true;
			}
		}

		return false;
	}

	// パラメータを動かす下限値と上限値の設定
	void setRange(double lowValue, double highValue)
	{
		m_low = lowValue;
		m_high = highValue;
	}

	// LFO のインデックスを設定
	void setModIndex(int index)
	{
		m_modIndex = index;
	}

	// LFO 設定を外す(通常のパラメータに戻す)
	void unsetModIndex()
	{
		m_modIndex = none;
	}

	// パラメータの値
	double value = 0;

private:

	double m_low = 0;
	double m_high = 1;
	Optional<int> m_modIndex;
};

変調機能をシンセサイザーに組み込む

まずパラメータ変数を ModParameter に置き換えて、Array<LFO> m_lfoStates を新たに追加します。

class Synthesizer
class Synthesizer
{
	...

-	double m_amplitude = 0.1;
-	double m_pan = 0.5;
-	double m_pitchShift = 0.0;

+	Array<LFO> m_lfoStates;

+	ModParameter m_amplitude = 0.1;
+	ModParameter m_pan = 0.5;
+	ModParameter m_pitchShift = 0.0;

	...
};

次にノートオンの間 LFO の更新処理を呼び出します。ここでオシレータの出力値を計算しておき、次の fetch(m_lfoStates) の呼び出しで実際にパラメータ値の書き変えを行います。

Synthesizer::renderSample()
WaveSample Synthesizer::renderSample()
{
	const auto deltaT = 1.0 / SamplingFreq;

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

+	// 再生中のノートがあれば LFO を更新する
+	if (!m_noteState.empty())
+	{
+		for (auto& lfoState : m_lfoStates)
+		{
+			lfoState.update(deltaT);
+		}
+	}

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

+	m_pitchShift.fetch(m_lfoStates);
-	const auto pitch = pow(2.0, m_pitchShift / 12.0);
+	const auto pitch = pow(2.0, m_pitchShift.value / 12.0);

	...

-	sample.left *= static_cast<float>(cos(Math::HalfPi * m_pan));
-	sample.right *= static_cast<float>(sin(Math::HalfPi * m_pan));
+	m_pan.fetch(m_lfoStates);
+	sample.left *= static_cast<float>(cos(Math::HalfPi * m_pan.value));
+	sample.right *= static_cast<float>(sin(Math::HalfPi * m_pan.value));

+	m_amplitude.fetch(m_lfoStates);
-	return sample * static_cast<float>(m_amplitude / sqrt(m_unisonCount));
+	return sample * static_cast<float>(m_amplitude.value / sqrt(m_unisonCount));
}

LFO が途中から再生されるのを防ぐために、ノートオンになるたび位相のリセットをかけます。ただし、モノモードを使うときは LFO も連続させたい場合が多いと思うので除外しています。

Synthesizer::noteOn()
void Synthesizer::noteOn(int8_t noteNumber, int8_t velocity)
{
	...

+	if (!m_mono)
+	{
+		// LFO の再生状態をリセットする
+		for (auto& lfoState : m_lfoStates)
+		{
+			lfoState.reset();
+		}
+	}
}

動作確認用にピッチをサイン波で揺らす設定の例を以下に示します。LFO に周期 0.1 秒のサイン波をセットして、ピッチを半音の幅で上下に動かしています。これを実行すると、音が若干震えているのがわかると思います。

void Main()
void Main()
{
	...

+	auto& lfoStates = synth.lfoStates();
+	lfoStates.resize(1);
+	lfoStates[0].setFunction(Sin);
+	lfoStates[0].setSeconds(0.1);
+	lfoStates[0].setLoop(true);

+	auto& pitchShift = synth.pitchShift();
+	pitchShift.setModIndex(0);
+	pitchShift.setRange(-0.5, 0.5);

	...
}

ここまでのコード

差分のみ

プログラム全体

サウンド作例:キック音

キック音はアタック時に高い周波数から素早く落ちるのが特徴で、これをピッチシフトで表現するために次のような減衰関数を定義します。

image.png

double WaveExp(double t)
{
	return exp(-t) * 2.0 - 1.0;
}

LFO に WaveExp を割り当てて、サイン波のピッチを落とすように設定するとキック音を作ることができます。

void Main()
{
	auto midiDataOpt = LoadMidi(U"C4.mid");

	...

	auto& synth = audioStream->synth();
	synth.setOscIndex(static_cast<int>(WaveForm::Sin));

	auto& adsr = synth.adsr();
	adsr.attackTime = 0.001;
	adsr.decayTime = 0.2;
	adsr.sustainLevel = 0.15;
	adsr.releaseTime = 0.2;

	auto& lfoStates = synth.lfoStates();
	lfoStates.resize(1);
	lfoStates[0].setFunction(WaveExp);
	lfoStates[0].setSeconds(0.1);
	lfoStates[0].setLoop(false);

	auto& pitchShift = synth.pitchShift();
	pitchShift.setModIndex(0);
	pitchShift.setRange(0, 48);

	...
}

サウンド作例:手拍子

手拍子の基本波形にはノイズの音を使います。手を叩くタイミングの微妙なずれを振幅の動きで再現するとそれっぽい音になります。したがって、LFO の先頭部分はノコギリ波で2~3回音の立ち上がりを出しておき、残りはキックで使った指数関数で減衰させます。

image.png

double WaveInvSaw(double t)
{
	return -2.0 * fmod(t, 0.2) / 0.2 + 1.0;
}

double WaveClap(double t)
{
	if (t < 0.5)
	{
		return WaveInvSaw(t);
	}
	else
	{
		return WaveExp(t);
	}
}

設定は下記の通りです。LFO だけだと減衰がちょっと遅かったのでエンベロープも重ねて掛けました。

void Main()
{
	auto midiDataOpt = LoadMidi(U"C4.mid");

	...

	auto& synth = audioStream->synth();
	synth.setOscIndex(static_cast<int>(WaveForm::Noise));

	auto& adsr = synth.adsr();
	adsr.attackTime = 0.001;
	adsr.decayTime = 0.2;
	adsr.sustainLevel = 0.0;
	adsr.releaseTime = 0.0;

	auto& lfoStates = synth.lfoStates();
	lfoStates.resize(1);
	lfoStates[0].setFunction(WaveClap);
	lfoStates[0].setSeconds(0.3);
	lfoStates[0].setLoop(false);

	auto& amplitude = synth.amplitude();
	amplitude.setModIndex(0);
	amplitude.setRange(0, 0.2);

	...
}

次回

書き中

5
2
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
5
2