前回
前回の記事では、ウェーブテーブルを使った基本波形の実装を行いました。最後に書いたコードの続きから作っていきます。
概要
今回は波形の合成や変調を行う部分を実装します。また、実装した機能を使ってサウンドをいくつか作ってみます。
準備:可視化機能を実装する
まず、前回の記事で使った可視化機能を再び使えるようにするためのコードを追加します。前回はAudio
をそのままAudioVisualizer
に渡していましたが、ストリーム再生を行う場合は内部で波形を取得できないので、自分で再生中の波形データをAudioVisualizer
に渡す必要があります。
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()
{
+ 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);
...
ここまでのコード
差分のみ
プログラム全体
1.パンとピッチシフト
音を左右に振るパン機能と、周波数を上下に動かすピッチシフト機能を実装します。
以降では Synthesizer
クラスにパラメータを追加しながら実装を行っていきます。
追加するパラメータ
pan
: [0.0, 1.0]
0.0 で左、0.5 で中央、1.0 で右から音が聞こえるようにします。向きによって音量が変わらないように、三角関数で正規化された係数を求めています。
+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
乗した値を周波数の係数とします。
+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 キーなどで半音単位にスナップさせると使いやすいです。
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.ユニゾン
ユニゾンは波形の周波数を少しずつずらしながら重ねることで、音にコーラスのような厚みを持たせる機能です。
追加するパラメータ
unisonCount
: [1, 16]
重ねる波形の本数です。多いほど音はきれいになりますが、この数だけオシレータを呼び出すことになるため処理負荷には注意が必要です。
detune
: [0.0, 1.0]
周波数のシフト量です。detune
で指定した最大幅をunisonCount
で分割して、各ユニゾン波形のピッチシフト量を計算します。ずらし過ぎるとただの不協和音になるので、半音ずれる量を最大値にしておきます。
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で各波形を真左から真右まで等間隔に配置した状態になるようにします。
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));
}
ユニゾンの実装
ノートごとに1つ持たせていた位相変数をユニゾンの数だけ持たせるようにします。動的に数を変えるとややこしいので、常に最大数を確保しています。
+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_detunePitch
と m_unisonPan
を使って波形にデチューンとパンを適用します。
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_detunePitch
とm_unisonPan
を再計算します。
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();
+ }
}
うなりを軽減する
近い周波数の波形を重ねると、波形間の位相が少しずつずれていくため、山同士が重なって音が大きくなるタイミングと、山と谷が打ち消しあって音が小さくなるタイミングが繰り返し発生します(これをうなりと言います)。
うなりを完全に消すことはできませんが、初期位相をランダムにセットすることで多少軽減することができます。
うなりの対策として、unisonCount
を増やす、spread
を大きめにかける、なども有効です(参考:https://sound.stackexchange.com/questions/37625/how-to-use-unison-without-voice-beating-phasing )
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()
{
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
: true | false
モノフォニックモードを有効化するフラグです。モノフォニックの時はNoteState
の数を1つまでに制限します。
legato
: true | false
キーが押されている途中に別のキーが押された時のエンベロープのリセット方法を指定するフラグです。オンの場合は音を止めずに滑らかに次の音に移行できるようにします。
true
の場合: エンベロープを Sustain ステートにセット
false
の場合: エンベロープを Attack ステートにセット
モノフォニックモードの実装
Synthesizer
クラスのノートオンイベントに、モノフォニック時の処理を追加します。
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
{
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 の修正
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 の修正
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
を追加しました。
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
{
double attackTime = 0.01;
double decayTime = 0.01;
double sustainLevel = 0.6;
+ double sustainResetTime = 0.05;
double releaseTime = 0.4;
};
Release の修正
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;
動作確認
mono
と legato
の動作確認用プログラムです。legato
がオフだと各ノート開始時のアタック音が聞こえて、オフだと常に一定の音量で鳴り続けるのが確認できると思います。
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;
...
未対応のケース
モノフォニック時の動作はシンプルに後から来たノートオンで常に上書きする仕様にしました。この実装はノートが互いに重なる場合(下図左)には期待通りに動作しますが、片方が完全に隠れる場合(下図右)は ノートが存在するのに鳴らない 区間が生じるので注意してください。
ここまでのコード
差分のみ
プログラム全体
4.グライド(ポルタメント)
連続したキー入力があったときに、キーの間で周波数を滑らかに補間する機能です。
追加するパラメータ
glide
: true | false
グライド機能を有効化するフラグです。
glideTime
: [0.001, 0.5]
ノートオンを受け取った後、現在の周波数からglideTime
秒かけて指定されたノートの周波数まで遷移させます。ただし、遷移方法はデフォルトで音階が直線的に動くようにしたいので 対数軸上での線形補間 で実装します。
線形補間:
$$
f = a + (b - a) t
$$
対数軸上での補間:
$$
f = a \times (b / a)^{t}
$$
グライドの実装
グライド機能に使うメンバ変数を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 noteOn(int8_t noteNumber, int8_t velocity)
{
...
+ if (m_mono && m_glide)
+ {
+ m_startGlideFreq = m_currentFreq;
+ m_glideElapsed = 0;
+ }
}
波形生成時にpow()
を使って周波数を補間してm_currentFreq
を更新します。
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;
...
ここまでのコード
差分のみ
プログラム全体
サウンド作例: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
クラス - パラメータの値を変化させる
ModParameter
クラス
LFO
クラス
LFO
クラスでは波形の位相を管理して、現在の LFO の出力値を保持しておきます。必要最小限のパラメータとして以下の項目を設定できるように実装します。
- 任意のカーブ設定
- 再生速度の設定
- ループする/しない
これ以外には、LFO の位相をリセットするトリガー条件の設定や、周期を四分音符や八分音符など曲のテンポに合わせる機能も付いていることが多いです。
カーブの設定
使用するカーブは外から設定できるように std::function<double(double)>
型のパラメータとして持たせておきます。関数の入出力範囲は通常のオシレータと同様に、入力範囲:$[0,2 \pi]$、出力範囲:$[-1,1]$ であるものとして扱います。
関数によっては計算コストが高い可能性があるので、問題になる場合は事前にウェーブテーブルに焼いた方が良いかもしれません。
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
{
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
{
...
- 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)
の呼び出しで実際にパラメータ値の書き変えを行います。
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 も連続させたい場合が多いと思うので除外しています。
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()
{
...
+ 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);
...
}
ここまでのコード
差分のみ
プログラム全体
サウンド作例:キック音
キック音はアタック時に高い周波数から素早く落ちるのが特徴で、これをピッチシフトで表現するために次のような減衰関数を定義します。
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回音の立ち上がりを出しておき、残りはキックで使った指数関数で減衰させます。
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);
...
}
次回
書き中