メインコンテンツまでスキップ

チュートリアルウェーブテーブルシンセシス

📚 Source Page

シンセサイザーのオシレーターを最適化するためにウェーブテーブルを組み込みます。ウェーブテーブルを使ってサイン波オシレーターの状態を管理し、オーディオ出力にデータを書き込もう。

レベル:中級

プラットフォーム:Windows, macOS, Linux

クラス: AudioBuffer,AudioAppComponent,Random,MathConstants

スタート

このチュートリアルのデモ・プロジェクトのダウンロードはこちらから:PIP|ZIP.プロジェクトを解凍し、最初のヘッダーファイルをProjucerで開く。

このステップにヘルプが必要な場合は、以下を参照してください。Tutorial: Projucer Part 1: Getting started with the Projucer.

デモ・プロジェクト

このデモ・プロジェクトは、ランダムなサイン波高調波のスタックを生成し、ステレオ出力で出力するだけです。ユーザー・インターフェースでは、従来のオシレーターの実装とウェーブテーブルを利用した実装を比較することで、CPU使用率をモニターすることができる。

異なる実装のCPU使用率を適切に評価・比較するために、アプリケーションをリリース通常のデバッグテストや開発時に使用されるコンフィギュレーション。プロジェクトをリリースモードでは、コンパイラーはコードからアサーションやコメントを削除したり、関数をインライン化したりすることで、可能な限りコードを最適化することができる。

Xcode でビルド構成を変更するには、まず、インターフェースの左上隅にあるデプロイメントターゲットをクリックし、次の場所に移動します。**編集スキーム...**以下の通りである:

スキームの編集
スキームの編集

ポップアップ・ウィンドウでリリースでのビルド構成コンボ・ボックスはスクリーン・ショットのとおりである:

ビルド構成の変更
ビルド構成の変更

あなたのアプリケーションは、コンパイラによる最適化後に実行され、CPU使用率は大幅に減少するはずです。

ウェーブテーブル

ウェーブテーブル・シンセシスとは、あらかじめ周期波形を埋め込んだルックアップテーブルを使用し、計算されたサンプルごとに同じ波形を生成することなくオシレーターを生成するシンセシス手法です。ウェーブテーブルは任意の周期波形で初期化され、これらの波形の分解能を指定することができます。出力する正しいサンプル値を取得する際、テーブル内のサンプル数とオーディオ・バッファ・ブロック内のサンプル数、および対応する要求周波数が一致しない場合は、2つのウェーブテーブル・サンプル間を補間して値を見つけます。

例として、ウェーブテーブルからサイン波を検索したいとします。まず、例えば128サンプル・ポイントの分解能で、サイン波の1サイクル分のウェーブテーブルを作成します。バッファ・ブロックの各サンプルについて、サンプル・レート、再生要求周波数、ウェーブテーブルの解像度、波形の現在の位相または角度の組み合わせを使って、正しい補間サンプルを計算することで、正弦波のサンプル値を要求することができます。

ウェーブテーブルに入る前に、簡単なサイン波オシレーターの実装から始めよう。

正弦波発振器

注記

このセクションの詳細はTutorial: Build a sine wave synthesiserもしこれらのステップで助けが必要な場合は、まずそのチュートリアルを参照してください。

の中でSineOscillatorクラスでは、波形サイクルの現在の角度または位相と、周波数とサンプル・レートに応じて各サイクル間で増分する角度デルタを格納する2つのメンバ変数を追跡します:

class SineOscillator
{
public:
SineOscillator() {}
//...

private:
float currentAngle = 0.0f, angleDelta = 0.0f;
};

についてsetFrequency()関数を使えば、まず周波数をサンプルレートで割り、その結果に2pi(ラジアン単位の1サイクルの長さ)を掛けることで、角度デルタを計算することができる:

    void setFrequency (float frequency, float sampleRate)
{
auto cyclesPerSample = frequency / sampleRate;
angleDelta = cyclesPerSample * juce::MathConstants::twoPi;
}

についてgetNextSample()関数が呼び出される。getNextAudioBlock()の機能である。AudioSourceを使用してサンプル値を計算します。ここでは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::twoPi)
currentAngle -= juce::MathConstants::twoPi;
}

では、次の実装に切り替えよう。MainContentComponentクラスである。

ここに示すように、出力全体のレベルとオシレーターの配列をプライベート・メンバー変数として管理している:

class MainContentComponent   : public juce::AudioAppComponent,
public juce::Timer
{
//...

private:
//...

float level = 0.0f;
juce::OwnedArray 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]を使用してランダムなMIDIノートを選択することもできます。Randomクラスの最低音を4オクターブずらし、その最低音を起点に3オクターブの音域を定義する。
  • [4]そのミディ・ノートの周波数を計算するには、簡単な数式を使ってA440の周波数に乗じるスカラーを求めます。A440のミディ・ノート番号は69であることがわかっているので、ミディ・ノート番号から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]次に、オーディオ・サンプル・バッファの各サンプルについて、サイン波のサンプルを取得し、レベル変数でゲインをトリミングする。
  • [10]最後に、そのサンプル値を左右のチャンネルのサンプルに足して、他のオシレーターと合計することができる。

今、アプリケーションを実行すると、正弦波を積み重ねたランダムなノイズが聞こえるはずだ。

エクササイズ

ランダムなMIDIノートを生成する代わりに、あるコードのMIDIノートを見つけ、そのコードからランダムなノートを生成する。

ウェーブテーブル・オシレーター

オシレーターの実装をウェーブテーブル・シンセシス方式に変えてみよう。

の中でMainContentComponentクラスのメンバ変数としてAudioSampleBufferを追加し、正弦波1サイクルのウェーブテーブル値を保持します。[1].また、ビットシフト演算子を用いて、ウェーブテーブルの分解能を128サンプルの定数として定義します。[2]:

private:
juce::Label cpuUsageLabel;
juce::Label cpuUsageText;

const unsigned int tableSize = 1 << 7; // [2]
float level = 0.0f;

juce::AudioSampleBuffer sineTable; // [1]
juce::OwnedArray oscillators;

JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainContentComponent)
};

という新しい関数を定義する。createWavetable()で呼び出される。MainContentComponentコンストラクタでオーディオ処理を開始する前に

    void createWavetable()
{
sineTable.setSize (1, (int) tableSize);
auto* samples = sineTable.getWritePointer (0); // [3]

auto angleDelta = juce::MathConstants::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]この関数では、AudioSampleBufferを初期化します。setSize()メソッドに、必要なのは1つのチャンネルだけで、サンプル数はテーブル・サイズと同じであることを指定する。そして、その単一チャンネル・バッファの書き込みポインタを取り出す。
  • [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サンプルを含むようになります。

のfor()ループではprepareToPlay()関数をインスタンス化するために、以下の行を変更する。WavetableOscillatorオブジェクトの代わりにSineOscillatorオブジェクトがある:

        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変数を定義して、使用するウェーブテーブルへの参照を保持する。

についてsetFrequency()の機能である。WavetableOscillatorクラスは、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つのインデックスの間の端数として補間値を計算する。これにより、次の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::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;

最後に、オシレーターの数を10に減らす。prepareToPlay()関数を実行し、アプリケーションを実行して結果を聞く。

エクササイズ

倍音を偶数系列に変更して、発生する音の音色の変化に注目してください。奇数系列と偶数系列はどうでしょう?

警告

オーディオ信号に高い周波数成分を加えるので、エイリアシング効果に注意する必要があります!このチュートリアルの範囲外ですが、ナイキスト・シャノンのサンプリング定理とアップサンプリン グについて読むとよいでしょう。

注記

この修正版のソースコードはWavetableSynthTutorial_04.hファイルにある。

備考

このチュートリアルでは、サイン波からウェーブテーブルを作成する方法について説明しましたが、最初のサンプルが最後のサンプルと一致していれば、基本的にどのような種類の周期波形でも保存することができます。

エクササイズ

を変更する。createWavetable()矩形波、三角波、ノコギリ波など、さまざまな種類の波形を生成して保存する機能。

概要

このチュートリアルでは、ウェーブテーブル・シンセサイザーの実装方法を学びました。特に

  • 正弦波オシレーターをウェーブテーブル・オシレーターに変換。
  • 数百の発振器でCPU使用率を最適化。
  • 同じ発振器のランダムな倍音をオーディオ出力に書き込む。
  • ハーモニクスとそのウェイトを選択することで、調和のとれたサウンドを作り出した。

参照