JUCEプログラミング、The fast Fourier transform2、スペクトログラムのデモアプリを実装

ProjucerのAudioテンプレからスペクトログラムのデモアプリを実装していきます。

アプリの構造を理解して今後に生かしていきたいね~

JUCEチュートリアル、「DSP」の「The fast Fourier transform」をやっていきます。前回はフーリエ変換の部分についてざっくりと理解をしましたので、その内容を踏まえてデモアプリの実装を行ってアプリの全体の構造を把握していきたいと思います。

前回の記事はこちらです

公式チュートリアルはこちらのページになります。

https://docs.juce.com/master/tutorial_simple_fft.html

目次

こんな人の役に立つかも

・JUCEチュートリアル「The fast Fourier transform」をやっている人

・スペクトログラムのアプリをJUCEで実装したい人

アプリの実装

プロジェクトの作成

Projucerの「Audio」テンプレートからプロジェクトを作成します。

Modulesを追加します。「juce_dsp」モジュールを選択します。

MainComponent.h

準備した「Audio」テンプレートプロジェクトファイルのMainComponent.hへ次のプログラムを追加します。

今回のアプリでは、一定時間毎に画面を描画更新するため、Timerクラスを継承([1])しておきます。

class MainComponent  : public juce::AudioAppComponent,
    private juce::Timer//[1]Timerを継承します。
{
public:
//...略...

    //[2]追加したpublicなメンバです。
    void timerCallback() override;
    void pushNextSampleIntoFifo(float sample) noexcept;
    void drawNextLineOfSpectrogram();

    static constexpr auto fftOrder = 10;
    static constexpr auto fftSize = 1 << fftOrder;

private:
    //[3]privateなメンバも追加しました。
    juce::dsp::FFT forwardFFT;
    juce::Image spectrogramImage;

    std::array<float, fftSize> fifo;
    std::array<float, fftSize * 2> fftData;
    int fifoIndex = 0;
    bool nextFFTBlockReady = false;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent)
};

[2]では、publicなメンバ関数として次の関数を定義します。

timerCallback():一定時間毎に呼び出されるタイマー処理です。

pushNextSampleIntoFifo():getNextAudioBuffer関数内で呼び出されて、音声バッファをfifoへと格納するような関数です。

drawNextLineOfSpectrogram():スペクトル表示画面を描画するための関数です。

次に、メンバ変数を定義しています。constexprはコンパイル時に計算されて定数となるようです。

https://qiita.com/KRiver1/items/ef7731467b5ca83850cb

今回の場合、fftOrderは定数「10」、fftSizeは定数「1024」となります。

[3]では、privateなメンバを定義しました。FFTのモジュールのオブジェクトfowardFFT、スペクトルを画像として表示するためのImageクラスのspectrogramImageを定義しています。

fifoは、「std::array」でfloat型の変数を1024個格納できる「std::array」のオブジェクトとなります。同様に、fftDataはfloat型の変数2048個格納できます。

fifoは音声バッファのデータを1024個取得するときに利用します。fftDataは、fifoをコピーして最初の1024個のデータとし、FFTに渡されます。その結果、2048個のデータが返ってきますので、FFTされたデータを保持するために利用されます。

fifoIndexとnextFFTBlockReadyも、それぞれ後程利用する変数になりますので、ここで定義しておきます。

MainComponent.cpp

コンストラクタ

コンストラクタは次のように書き換えました。

MainComponent::MainComponent() : forwardFFT(fftOrder),//[1]FFTの初期化です。
spectrogramImage(juce::Image::RGB, 512, 512, true)//[2]スペクトル画像の初期化です。
{
    //[3]アプリの初期設定です。
    setOpaque(true);
    setAudioChannels(2, 0);
    startTimerHz(60);
    setSize(700, 500);
}

[1]では、FFTモジュールをメンバイニシャライザで初期化しています。dsp::FFTクラスの初期化には、引数に2のべき乗を与えます。fftOrderは10なので、1024(2^10)をデータ数としてFFTクラスを初期化しています。

[2]では、juce:Imageクラスの初期化を行っています。GUIに表示するための周波数情報を描画するための画像です。

[3]は、アプリの初期設定です。setOpaqueでアプリの親コンポーネントが不透明であることを明示しています。また、setAudioChannelsで音声入出力を、入力2チャンネル、出力なし、と設定しています。startTimerHzは、60Hzでタイマーの処理を行うように設定します。

getNextAudioBlock関数

getNextAudioBlock関数を次のように変更しました。

getNextAudioでは、入力音声バッファを処理します。入力音声はbufferToFillで参照できます。

void MainComponent::getNextAudioBlock (const juce::AudioSourceChannelInfo& bufferToFill)
{
    if (bufferToFill.buffer->getNumChannels() > 0)//[1]チャンネルが1以上ある場合です。
    {
        //[2]0チャンネル目のバッファの1番目サンプルを取得します。
        auto* channelData = bufferToFill.buffer->getReadPointer(0, bufferToFill.startSample);

        //[3]バッファのサンプル数分ループを行います。
        for (auto i = 0; i < bufferToFill.numSamples; ++i)
            pushNextSampleIntoFifo(channelData[i]);
    }
}

[1]で、入力された音声バッファはbufferToFillのメンバ、bufferに格納されます。bufferはAudioBufferクラスのオブジェクトなので、getNumChannels関数を呼び出し、音声が0チャンネルではないことを確認します。

[2]では、getReadPointer関数で0チャンネル目の音声バッファの最初のサンプルを取得します。(チャンネルは0番目から始まるようです。)ということで、今回の音声は2チャンネルありますが、実質1つ目のチャンネルしか利用していません。channelDataに0チャンネル目の音声バッファのポインタが格納されます。これで、channelData[0]のように音声データの配列へのアクセスができるようになります。

[3]では、音声データをbufferToFillのデータサンプル数分繰り返しています。音声データはそのままpushNextSampleIntoFifo関数へ渡されることになります。

pushNextSampleIntoFifo

pushNextSampleIntoFifo関数を以下のようにcppファイルに追加しました。

void MainComponent::pushNextSampleIntoFifo(float sample) noexcept
{
    //[1]サンプル数をfftへ入れるデータ数にいっぱいになった場合です。
    if (fifoIndex == fftSize)
    {
        //[1-1]FFTのフラグがfalseのときのみ実行です。
        if (!nextFFTBlockReady)
        {
            std::fill(fftData.begin(), fftData.end(), 0.0f);
            std::copy(fifo.begin(), fifo.end(), fftData.begin());
            nextFFTBlockReady = true;
        }

        //[1-2]fifoIndexをリセットします。
        fifoIndex = 0;
    }

    //[2]fifo配列にサンプルを格納していきます。
    fifo[(size_t)fifoIndex++] = sample; // [9]
}

fifoIndexは整数のprivateなメンバ変数でした。処理の回数を繰り返すとfifoIndexは1ずつ増加していきます。[2]の処理のように、この関数は受け取った音声データsampleをfifo配列に順番に格納する働きになります。

[1]の処理は、fftSize(今回は1024)回の処理が実行されるとfftIndexの値が1024になり実行されます。fifo配列が満タンになったときの処理となります。

[1-1]では、nextFFTBlockReadyというフラグがFalseのとき実行されます。まずは、fill関数でfftData配列全体にに0を格納します。次にcopyでfifo配列の最初から最後をfftDataの最初の位置を起点としてコピーします。

fifoは1024個、fftDataは2048個の配列なので、fftDataには最初の半分に音声データがコピーされることになります。これが今回FFTに渡すデータになります。

そして、nextFFTBlockReadyフラグをtrueにすることで、fftDataが再度繰り返される[1-1]の処理で変更されないようにします。この後も、fifo自体はfifoIndexが0になるので、1024個のサンプルを0番目から埋める処理を継続します。getNextAudioBuffer関数は常に連続的に動作していますので、fifoは1024サンプルをひたすら区切り続けるようなイメージで動作しています。

一方で、fftDataはnextFFTBlockReadyフラグがfalseに戻ったタイミングで更新されますので、FFTの処理タイミング(今回はタイマー処理)にゆだねられることになります。

paint関数

paint関数を次のように実装します。

void MainComponent::paint (juce::Graphics& g)
{
    g.fillAll(juce::Colours::black);

    g.setOpacity(1.0f);
    g.drawImage(spectrogramImage, getLocalBounds().toFloat());
}

fillAllで全体を黒色に塗りつぶし、setOpacity関数でこの後にgで描画する際の透明度の設定を完全に不透明にしています。

そして、drawImage関数で、「spectrogramImage」を描画します。描画領域として、getLocalBounds()で描画領域全体を取得しています。引数に与えるにはfloatのRectangleとする必要があるのでtoFloat関数でRectangle<float>にキャストします。

TimerCallback関数

TimerCallback関数をcppファイルに追加しました。この関数は、コンストラクタで1秒当たり60回呼び出されるように設定しました。

void MainComponent::timerCallback()
{
    if (nextFFTBlockReady)
    {
        drawNextLineOfSpectrogram();
        nextFFTBlockReady = false;
        repaint();
    }
}

処理内容として、「nextFFTBlockReady」がTrueのとき(fftDataにデータの準備ができている時)を条件として、drawNextLineOfSpectrogram関数を呼び出します。そして、repaintで画面の再描画を行います。

この処理でnextFFTBlockReadyをfalseにすることで、次のfftDataを取得するようにします。

drawNextLineOfSpectrogram

cppに、次の関数も追加しました。この関数で、スペクトログラムの画像作成を行います。

void MainComponent::drawNextLineOfSpectrogram()
{
    auto rightHandEdge = spectrogramImage.getWidth() - 1;
    auto imageHeight = spectrogramImage.getHeight();

    spectrogramImage.moveImageSection(0, 0, 1, 0, rightHandEdge, imageHeight);

    forwardFFT.performFrequencyOnlyForwardTransform(fftData.data());

    auto maxLevel = juce::FloatVectorOperations::findMinAndMax(fftData.data(), fftSize / 2);

    for (auto y = 1; y < imageHeight; ++y)
    {
        auto skewedProportionY = 1.0f - std::exp(std::log((float)y / (float)imageHeight) * 0.2f);
        auto fftDataIndex = (size_t)juce::jlimit(0, fftSize / 2, (int)(skewedProportionY * fftSize / 2));
        auto level = juce::jmap(fftData[fftDataIndex], 0.0f, juce::jmax(maxLevel.getEnd(), 1e-5f), 0.0f, 1.0f);

        spectrogramImage.setPixelAt(rightHandEdge, y, juce::Colour::fromHSV(level, 1.0f, level, 1.0f));
    }
}

この関数の処理内容としては、意外と複雑なことをしていたので、別記事として処理内容を詳細に見ていきたいと思います。今回は、スペクトログラムの画像を作成する処理とだけ書いて実装してしまいます。

全体の流れ

アプリ自体としては、音声データをFFT用に準備し続ける音声処理スレッドと、fftを行って画像としてスペクトログラムを表示し続ける描画スレッド(と言っていいのかわかりませんが^^;)がお互いに「nextFFTBlockReady」フラグでデータの連動をしつつ、独立で動作しているようなイメージとなりました。

音声処理スレッドは、ひたすら音声バッファを取得し続ける必要があるので、第1のデータの受け手としてfifo配列があり、そのタイミングを見計らって描画処理との速度を調整している第2のデータfftDataがあるようなイメージです。

音声処理スレッドは止めることをしてはいけないので、時間のかかる処理などは、このように音声処理スレッドから分離させて実装していくという点が勉強になります。

音声処理スレッドを止めないのがポイントですね

よかったらシェアしてね!
目次
閉じる