チュートリアル: ウェーブテーブルシンセシス
ウェーブテーブルを組み込んで、シンセサイザーオシレーターを最適化します。ウェーブテーブルを使用してサイン波オシレーターの状態を管理し、音声出力にデータを書き込みます。
レベル: 中級
プラットフォーム: Windows, macOS, Linux
クラス: AudioBuffer, AudioAppComponent, Random, MathConstants
はじめに
このチュートリアルのデモプロジェクトをダウンロードしてください: PIP | ZIP。プロジェクトを解凍し、Projucerで最初のヘッダーファイルを開いてください。
この手順でヘルプが必要な場合は、Tutorial: Projucer Part 1: Getting started with the Projucerを参照してください。
デモプロジェクト
このデモプロジェクトは、ランダムなサイン波の高調波のスタックを生成し、ステレオ出力から出力するだけです。ユーザーインターフェイスにより、従来のオシレーターの実装とウェーブテーブルを使用する実装を比較して、CPU使用率を監視できます。
さまざまな実装のCPU使用率を適切に評価および比較するために、テストおよび開発中に使用される通常のDebug構成ではなく、Release構成でアプリケーションを実行します。Releaseモードでプロジェクトをビルドすることで、コンパイラは、たとえばコードからアサーションとコメントを削除し、関数をインライン化することで、コードを可能な限り最適化できます。
Xcodeでビルド構成を変更するには、まずインターフェイスの左上隅にあるデプロイメントターゲットをクリックし、以下に示すように**Edit Scheme...**に移動します:

ポップアップウィンドウで、スクリーンショットに示すように、Build ConfigurationコンボボックスでReleaseを選択します:

アプリケーションは、大幅なコンパイラ最適化の後に実行され、CPU使用率は大幅に減少するはずです。
ウェーブテーブル
ウェーブテーブル合成は、周期的な波形で事前に埋められたルックアップテーブルを使用して、計算された各サンプルに対して同じ波形を生成することなくオシレーターを生成する合成方法です。ウェーブテーブルは、選択した周期的な波形で初期化され、これらの波形の解像度を指定できます。出力する正しいサンプル値を取得する場合、テーブル内のサンプル数が音声バッファブロック内のサンプル数とそれに対応する要求された周波数と一致しない場合、2つのウェーブテーブルサンプル間を補間することによって値が見つかります。
例として、ウェーブテーブルからサイン波を検索したいとしましょう。まず、たとえば128サンプルポイントの解像度でサイン波の単一サイクルのウェーブテーブルを作成します。バッファブロック内の各サンプルについて、サンプルレート、再生する要求された周波数、ウェーブテーブルの解像度、波形の現在の位相または角度の組み合わせを使用して、正しい補間されたサンプルを計算することにより、サイン波サンプル値を要求できます。
ウェーブテーブルに飛び込む前に、シンプルなサイン波オシレーターの実装から始めましょう。
サイン波オシレーター
このセクションは、Tutorial: Build a sine wave synthesiserでより詳しく説明されています。これらの手順でヘルプが必要な場合は、まずそのチュートリアルを参照してください。
SineOscillatorクラスでは、波形サイクル内の現在の角度または位相と、周波数とサンプルレートに応じてすべてのサイクル間でインクリメントする角度デルタを格納する2つのメンバー変数を追跡します:
class SineOscillator
{
public:
SineOscillator() {}
//...
private:
float currentAngle = 0.0f, angleDelta = 0.0f;
};
setFrequency()関数を使用すると、まず周波数をサンプルレートで割り、結果にサイクルの長さ(ラジアンで2pi)を掛けることで、角度デルタを計算できます:
void setFrequency (float frequency, float sampleRate)
{
auto cyclesPerSample = frequency / sampleRate;
angleDelta = cyclesPerSample * juce::MathConstants<float>::twoPi;
}
getNextSample()関数は、バッファ内のすべてのサンプルでオシレーターからサンプル値を取得するために、AudioSourceのgetNextAudioBlock()関数によって呼び出されます。ここでは、現在の角度を引数として渡してstd::sin()関数を使用してサンプル値を計算し、次に定義されたヘルパー関数updateAngle()を呼び出して現在の角度を更新します:
forcedinline float getNextSample() noexcept
{
auto currentSample = std::sin (currentAngle);
updateAngle();
return currentSample;
}
角度は、周波数を設定するときに以前に計算された角度デルタで現在の角度をインクリメントし、角度が2piを超えたときに値をラップすることで更新されます:
forcedinline void updateAngle() noexcept
{
currentAngle += angleDelta;
if (currentAngle >= juce::MathConstants<float>::twoPi)
currentAngle -= juce::MathConstants<float>::twoPi;
}
それでは、MainContentComponentクラスの実装に切り替えましょう。
ここに示すように、出力の全体的なレベルとオシレーターの配列をprivateメンバー変数として追跡します:
class MainContentComponent : public juce::AudioAppComponent,
public juce::Timer
{
//...
private:
//...
float level = 0.0f;
juce::OwnedArray<SineOscillator> oscillators;
//...
};
prepareToPlay()関数では、次のようにサンプルレートに基づいて再生する周波数を設定し、オシレーターを初期化する必要があります:
void prepareToPlay (int, double sampleRate) override
{
auto numberOfOscillators = 200; // [1]
for (auto i = 0; i < numberOfOscillators; ++i)
{
auto* oscillator = new SineOscillator(); // [2]
auto midiNote = juce::Random::getSystemRandom().nextDouble() * 36.0 + 48.0; // [3]
auto frequency = 440.0 * pow (2.0, (midiNote - 69.0) / 12.0); // [4]
oscillator->setFrequency ((float) frequency, (float) sampleRate); // [5]
oscillators.add (oscillator);
}
level = 0.25f / (float) numberOfOscillators; // [6]
}
- [1]: まず、CPU負荷を評価するために多数のオシレーターを定義します。
- [2]: 各オシレーターについて、単一のサイン波ボイスを生成する新しい
SineOscillatorオブジェクトをインスタンス化します。 - [3]: また、Randomクラスを使用して、可能な最低音を4オクターブシフトし、その最低音から3オクターブの範囲を定義することで、ランダムなMIDIノートを選択します。
- [4]: そのMIDIノートの周波数を計算するために、A440の周波数を掛けるスカラーを取得するためのシンプルな数式を使用します。A440のMIDIノート番号が69であることがわかっているので、MIDIノートから69を引くことで、A440からの半音距離が得られ、次の式に代入できます:
440 \* 2 \^ (d / 12) - [5]: 次に、周波数とサンプルレートを引数として
setFrequency()関数に渡すことで、オシレーターの周波数を設定します。また、オシレーターをオシレーターの配列に追加します。 - [6]: 最後に、そのような多数のオシレーターサンプルを合計することによる信号のクリッピングを防ぐために、静かなゲインレベルをオシレーターの数で割ることで出力レベルを定義します。
getNextAudioBlock()関数では、以下に示すように、すべてのオシレーターサンプルを合計し、結果を出力バッファに書き込むだけです:
void getNextAudioBlock (const juce::AudioSourceChannelInfo& bufferToFill) override
{
auto* leftBuffer = bufferToFill.buffer->getWritePointer (0, bufferToFill.startSample); // [7]
auto* rightBuffer = bufferToFill.buffer->getWritePointer (1, bufferToFill.startSample);
bufferToFill.clearActiveBufferRegion();
for (auto oscillatorIndex = 0; oscillatorIndex < oscillators.size(); ++oscillatorIndex)
{
auto* oscillator = oscillators.getUnchecked (oscillatorIndex); // [8]
for (auto sample = 0; sample < bufferToFill.numSamples; ++sample)
{
auto levelSample = oscillator->getNextSample() * level; // [9]
leftBuffer[sample] += levelSample; // [10]
rightBuffer[sample] += levelSample;
}
}
}
- [7]: まず、出力バッファに書き込むために左右のチャンネルポインタを取得します。
- [8]: 配列内の各オシレーターについて、オシレーターインスタンスへのポインタを取得します。
- [9]: 次に、音声サンプルバッファ内の各サンプルについて、サイン波サンプルを取得し、level変数でゲインをトリミングします。
- [10]: 最後に、そのサンプル値を左右のチャンネルサンプルに追加し、他のオシレーターと信号を合計できます。
アプリケーションを今すぐ実行すると、重ねられたサイン波のランダムなノイズが聞こえるはずです。
演習: ランダムなMIDIノートを生成する代わりに、特定のコードのMIDIノートを見つけて、コードからランダムなノートを生成します。
ウェーブテーブルオシレーター
オシレーターの実装をウェーブテーブル合成方法に変更しましょう。
MainContentComponentクラスに、単一のサイン波サイクルのウェーブテーブル値を保持するAudioSampleBufferをメンバー変数として追加します[1]。また、ビットシフト演算子[2]を使用して、ウェーブテーブルの解像度を128サンプルの定数として定義します:
private:
juce::Label cpuUsageLabel;
juce::Label cpuUsageText;
const unsigned int tableSize = 1 << 7; // [2]
float level = 0.0f;
juce::AudioSampleBuffer sineTable; // [1]
juce::OwnedArray<WavetableOscillator> oscillators;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainContentComponent)
};
音声処理を開始する前にMainContentComponentコンストラクタで呼び出されるcreateWavetable()という新しい関数を定義します。
void createWavetable()
{
sineTable.setSize (1, (int) tableSize);
auto* samples = sineTable.getWritePointer (0); // [3]
auto angleDelta = juce::MathConstants<double>::twoPi / (double) (tableSize - 1); // [4]
auto currentAngle = 0.0;
for (unsigned int i = 0; i < tableSize; ++i)
{
auto sample = std::sin (currentAngle); // [5]
samples[i] = (float) sample;
currentAngle += angleDelta;
}
}
- [3]: この関数では、1つのチャンネルのみが必要であり、サンプル数がテーブルサイズ(この場合は解像度128)に等しいことを指定して、
setSize()メソッドを呼び出してAudioSampleBufferを初期化します。次に、その単一チャンネルバッファの書き込みポインタを取得します。 - [4]: 次に、前のセクションと同様に角度デルタを計算しますが、今回はテーブルサイズを使用するため、完全な2piサイクルを127で割ります。
- [5]: ウェーブテーブルの各ポイントについて、
std::sin()関数を使用してサイン波値を取得し、バッファサンプルに値を割り当て、デルタ値で現在の角度をインクリメントします。
次のように、MainContentComponentコンストラクタにこの関数呼び出しを追加します:
MainContentComponent()
{
cpuUsageLabel.setText ("CPU Usage", juce::dontSendNotification);
cpuUsageText.setJustificationType (juce::Justification::right);
addAndMakeVisible (cpuUsageLabel);
addAndMakeVisible (cpuUsageText);
createWavetable();
setSize (400, 200);
setAudioChannels (0, 2); // no inputs, two outputs
startTimer (50);
}
ウェーブテーブルには、完全なサイン波サイクルの128サンプルが含まれているはずです。
prepareToPlay()関数のfor()ループで、以下の行を変更して、SineOscillatorオブジェクトの代わりにWavetableOscillatorオブジェクトをインスタンス化します:
for (auto i = 0; i < numberOfOscillators; ++i)
{
auto* oscillator = new WavetableOscillator (sineTable);
このコンストラクタは、サウンド生成に使用するウェーブテーブルを引数として受け取るため、以下に示すように、対応する新しいWavetableOscillatorクラスを作成します:
class WavetableOscillator
{
public:
WavetableOscillator (const juce::AudioSampleBuffer& wavetableToUse)
: wavetable (wavetableToUse)
{
jassert (wavetable.getNumChannels() == 1);
}
private:
const juce::AudioSampleBuffer& wavetable;
float currentIndex = 0.0f, tableDelta = 0.0f;
};
波形サイクルの現在の角度と角度デルタを追跡する代わりに、現在のウェーブテーブルインデックスとウェーブテーブルの角度デルタを格納する2つのメンバー変数を定義します。また、使用するウェーブテーブルへの参照を保持するAudioSampleBuffer変数を定義します。
WavetableOscillatorクラスのsetFrequency()関数は、以前に実装したものとかなり似ていますが、角度デルタが2piの完全なサイクルではなく、次のようにウェーブテーブルのサイズを使用して計算される点が異なります:
void setFrequency (float frequency, float sampleRate)
{
auto tableSizeOverSampleRate = (float) wavetable.getNumSamples() / sampleRate;
tableDelta = frequency * tableSizeOverSampleRate;
}
getNextSample()関数は、正しいサンプル値を取得するために、ウェーブテーブル値間の補間が発生する場所です。
forcedinline float getNextSample() noexcept
{
auto tableSize = (unsigned int) wavetable.getNumSamples();
auto index0 = (unsigned int) currentIndex; // [6]
auto index1 = index0 == (tableSize - 1) ? (unsigned int) 0 : index0 + 1;
auto frac = currentIndex - (float) index0; // [7]
auto* table = wavetable.getReadPointer (0); // [8]
auto value0 = table[index0];
auto value1 = table[index1];
auto currentSample = value0 + frac * (value1 - value0); // [9]
if ((currentIndex += tableDelta) > (float) tableSize) // [10]
currentIndex -= (float) tableSize;
return currentSample;
}
- [6]: まず、取得しようとしているサンプル値を囲むウェーブテーブルの2つのインデックスを一時的に保存します。高い方のインデックスがウェーブテーブルのサイズを超える場合、値をテーブルの先頭にラップします。
- [7]: 次に、実際の現在のサンプルから切り捨てられた下位インデックスを引くことで、2つのインデックス間の分数として補間値を計算します。これにより、分数を定義する
0 .. 1の間の値が得られるはずです。 - [8]: 次に、AudioSampleBufferへのポインタを取得し、2つのインデックスの値を読み取り、これらの値を一時的に保存します。
- [9]: 次に、標準の補間式と以前に計算された分数値を使用して、補間されたサンプル値を取得できます。
- [10]: 最後に、テーブルの角度デルタをインクリメントし、値がテーブルサイズを超える場合は値をラップします。
この実装により、アプリケーションを実行すると同じ出力サウンドが得られるはずです。
演習: オシレーターの数を変更し、CPU使用率の変化を観察します。
このコードの変更版のソースコードは、デモプロジェクトのWavetableSynthTutorial_02.hファイルにあります。
ウェーブテーブルのラッピング
前のコードに細心の注意を払った場合、ウェーブテーブルに1つの欠落値があることに気付いたかもしれません。最後の値はスキップされ、同じである最初の値にラップされるため、今すぐ修正しましょう。
WavetableOscillatorコンストラクタで、テーブルサイズ変数を割り当てて、ウェーブテーブルの解像度から1を引いた値を保持し、次のようにそのメンバー変数を適切に定義します:
class WavetableOscillator
{
public:
WavetableOscillator (const juce::AudioSampleBuffer& wavetableToUse)
: wavetable (wavetableToUse),
tableSize (wavetable.getNumSamples() - 1)
{
jassert (wavetable.getNumChannels() == 1);
}
private:
const juce::AudioSampleBuffer& wavetable;
const int tableSize;
float currentIndex = 0.0f, tableDelta = 0.0f;
};
setFrequency()関数は、この変数を使用して更新する必要があり、テーブルの角度デルタがわずかに小さくなることに注意してください:
void setFrequency (float frequency, float sampleRate)
{
auto tableSizeOverSampleRate = (float) tableSize / sampleRate;
tableDelta = frequency * tableSizeOverSampleRate;
}
getNextSample()関数は、次のステップでテーブルのサイズを増やすため、高い方のインデックスをラップする必要がなくなる点を除いて、かなり似たままです:
forcedinline float getNextSample() noexcept
{
auto index0 = (unsigned int) currentIndex;
auto index1 = index0 + 1;
ここでは、以前とは異なり、定義された値より1つ上の解像度を設定し、最後のサンプルを最初のサンプルと同じように設定します:
void createWavetable()
{
sineTable.setSize (1, (int) tableSize + 1);
auto* samples = sineTable.getWritePointer (0);
samples[tableSize] = samples[0];
}
これにより、処理呼び出しのラッピング条件を減らし、アプリケーションの開始時に一度だけ呼び出されるcreateWavetable()関数に負荷を移すことができます。
結果は前のセクションと同じように聞こえるはずですが、CPU使用率のわずかな減少に注意してください。
演習: このコードをさらに最適化する方法を見つけることができますか? DSPでのすべての算術演算はパフォーマンスに影響するため、可能な限り多くの演算を削除する必要があります。
このコードの変更版のソースコードは、デモプロジェクトのWavetableSynthTutorial_03.hファイルにあります。
高調波の選択
ランダムなサイン波サウンドを出力する代わりに、高調波を明示的に設定して調和のとれたサイン波を作成しましょう。
次のように、サイン波のウェーブテーブルサンプルに高調波を組み込むようにcreateWavetable()関数を変更します:
void createWavetable()
{
sineTable.setSize (1, (int) tableSize + 1);
sineTable.clear();
auto* samples = sineTable.getWritePointer (0);
int harmonics[] = { 1, 3, 5, 6, 7, 9, 13, 15 };
float harmonicWeights[] = { 0.5f, 0.1f, 0.05f, 0.125f, 0.09f, 0.005f, 0.002f, 0.001f }; // [1]
jassert (juce::numElementsInArray (harmonics) == juce::numElementsInArray (harmonicWeights));
for (auto harmonic = 0; harmonic < juce::numElementsInArray (harmonics); ++harmonic)
{
auto angleDelta = juce::MathConstants<double>::twoPi / (double) (tableSize - 1) * harmonics[harmonic]; // [2]
auto currentAngle = 0.0;
for (unsigned int i = 0; i < tableSize; ++i)
{
auto sample = std::sin (currentAngle);
samples[i] += (float) sample * harmonicWeights[harmonic]; // [3]
currentAngle += angleDelta;
}
}
samples[tableSize] = samples[0];
}
- [1]: 奇数高調波のインデックスとそれに対応するウェイトをそれぞれ記述する2つの配列を定義します。
- [2]: 各高調波について、完全な2piサイクルに高調波次数を掛け、テーブルサイズで割ることで角度デルタを計算します。これは、生成される周波数に高調波次数を掛けることになります。
- [3]: テーブル内の各サンプルについて、現在の角度からサイン値を取得し、対応する高調波ウェイトでゲインをトリミングして既存のバッファサンプルに値を追加し、デルタ値で現在の角度をインクリメントします。
void prepareToPlay (int, double sampleRate) override
{
auto numberOfOscillators = 10;
最後に、prepareToPlay()関数でオシレーターの数を10に減らし、アプリケーションを実行して結果を聞きます。
演習: 高調波を偶数シリーズに変更し、生成されるサウンドの音色の変化に注意してください。奇数と偶数のシリーズはどうですか?
音声信号に高周波成分を追加しているため、エイリアシング効果に注意する必要があります! これらの対処はこのチュートリアルの範囲を超えていますが、Nyquist-Shannonサンプリング定理とアップサンプリングについて読むことが良い出発点になります。
このコードの変更版のソースコードは、デモプロジェクトのWavetableSynthTutorial_04.hファイルにあります。
Notes
このチュートリアルでは、サイン波からウェーブテーブルを作成する方法を探りましたが、最初のサンプルが最後のサンプルと一致する限り、基本的に任意のタイプの周期的な波形を選択して格納できます。
演習: createWavetable()関数を変更して、矩形波、三角波、のこぎり波などのさまざまなタイプの波形を生成して保存します。
まとめ
このチュートリアルでは、ウェーブテーブルシンセサイザーの実装方法を学びました。特に、以下を行いました:
- サイン波オシレーターをウェーブテーブルオシレーターに変換しました。
- 数百のオシレーターでCPU使用率を最適化しました。
- 同じオシレーターのランダムな高調波を音声出力に書き込みました。
- 高調波とそのウェイトを選択して調和のとれたサウンドを作成しました。
関連項目
- Tutorial: Build a white noise generator
- Tutorial: Build a sine wave synthesiser
- Tutorial: Control audio levels using decibels
- Tutorial: Control audio levels
- Tutorial: Build a MIDI synthesiser
- Tutorial: Looping audio using the AudioSampleBuffer class
- Tutorial: Looping audio using the AudioSampleBuffer class (advanced)