# week 4 contributed by < `robertlin0401` > ###### tags: `數位音樂訊號分析` > [GitHub](https://github.com/robertlin0401/Digital-Music-Signal-week4) --- ## 課堂範例 * [程式範例](https://github.com/datuiji/SineWaveSynthesizer/commit/1df1356a7914d647a6b9718b992c084bc509e009) * [說明文件](https://hackmd.io/@datuiji/JUCE_Framwork_GUI) --- ## 修改 slider ### 簡述 * 此階段的目標 1. 將 slider 改成旋轉式(Rotary),以便後續客製化成旋鈕的樣式 2. 引入 FlexBox 協助進行排版 * 原本的介面如下 ![](https://i.imgur.com/M8fkRop.png) ### 實作 * 定義兩個 class:`MySlider` 與 `SliderContainer` * 在 `MySlider` 中主要會設定個別的 slider 其本身的格式,以下為此 class 的 constructor ```cpp MySlider::MySlider(MyAudioProcessor &p, juce::String labelName) : audioProcessor(p) { setLookAndFeel(&lnf); slider.setSliderStyle(juce::Slider::SliderStyle::Rotary); slider.setTextBoxStyle( juce::Slider::TextBoxBelow, true, slider.getTextBoxWidth(), slider.getTextBoxHeight() ); label.setText(labelName, juce::dontSendNotification); label.setJustificationType(juce::Justification::centred); attachment.reset(new juce::AudioProcessorValueTreeState::SliderAttachment( audioProcessor.tree, juce::String{labelName}.toLowerCase(), slider )); addAndMakeVisible(slider); addAndMakeVisible(label); } ``` * `setLookAndFeel(&lnf)` 用於設定自定義的樣式 * 為此,在 `Slider` 的 destructor 需要執行以下操作 ```cpp MySlider::~MySlider() { setLookAndFeel(nullptr); } ``` * 其中,`lnf` 變數為 `MyLookAndFeel` class 的物件,自定義的樣式將在此 class 中實作 * `juce::Slider::SliderStyle::Rotary` 表示使用旋轉式的 slider,其操作方式為沿著圓周拖動,類似於轉動真實旋鈕的動作 > [SliderStyle docs](https://docs.juce.com/master/classSlider.html#af1caee82552143dd9ff0fc9f0cdc0888) * `juce::Slider::TextBoxBelow` 表示將顯示數值的文字方塊放置在 slider 下方 > [TextEntryBoxPosition docs](https://docs.juce.com/master/classSlider.html#ab6d7dff67151c029b9cb53fc40b4412f) * 在 `SliderContainer` 中,透過實作 `resize()` 函式來調整多個 sliders 之間的排版方式,其中,使用了 FlexBox 來輔助排版 ```cpp void SliderContainer::resized() { juce::FlexBox flexBox; for (auto &slider : sliders) { juce::FlexItem item {*slider}; flexBox.items.add(item.withFlex(1.0)); } flexBox.performLayout(getLocalBounds()); for (auto &slider : sliders) { slider->setBounds(slider->getBounds().reduced(5)); } } ``` :::warning :notebook_with_decorative_cover:此處的 for 迴圈中,出現了我沒看過的語法,以下筆記 1. `slider : sliders` * 此為自 C++11 起所提供的迭代方法,稱為 range-based for loop,此處用於遍歷 vector `sliders`,且每次迭代可用變數名稱 `slider` 來存取該次的迭代對象 > [cppreference 說明](https://en.cppreference.com/w/cpp/language/range-for) 2. `auto &` * 用於表示變數的存取方法,此處表示使用 access by reference 的方式來存取 `slider` * 另外還有 `auto` 與 `auto &&` 的用法,分別表示使用 access by value 與 access by forwarding reference 的存取方式 > [stackoverflow 說明](https://stackoverflow.com/questions/29859796/c-auto-vs-auto#answer-29860056) > [cppreference 範例](https://en.cppreference.com/w/cpp/language/range-for#Example) ::: ### 成果 ![](https://i.imgur.com/Sskaed2.png) * 由於尚未實作 `MyLookAndFeel` 的部分,因此 slider 的樣式為預設的樣式 ## 自定義 slider 樣式 ### 簡述 * 此階段的目標:將 slider 改成自定義的樣式 * 實作方法:由於是要設計旋轉式 slider,因此僅需實作 `MyLookAndFeel` class 中的 `drawRotarySlider()` 函式即可 * 參考樣式 ![](https://i.imgur.com/4iRt5DS.png) ### 實作 ```cpp void drawRotarySlider(juce::Graphics &g, int x, int y, int width, int height, float sliderPos, const float rotaryStartAngle, const float rotaryEndAngle, juce::Slider& slider) override { auto bound = juce::Rectangle<int>(x, y, width, height).toFloat().reduced(10); auto radius = juce::jmin(bound.getWidth(), bound.getHeight()) / 2.0f; auto centreX = bound.getCentreX(); auto centreY = bound.getCentreY(); auto angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); // fill auto rx = centreX - radius; auto ry = centreY - radius; auto rw = radius * 2.0f; g.setColour(juce::Colours::black); g.fillEllipse(rx + 1.0f, ry + 1.0f, rw + 2.5f, rw + 2.5f); g.setColour(juce::Colour::fromString("FF3C3C3C")); g.fillEllipse(rx, ry, rw, rw); // outline g.setColour(juce::Colours::grey); g.drawEllipse(rx, ry, rw, rw, 3.0f); // pointer juce::Path p; auto pointerLength = radius * 0.33f; auto pointerThickness = 2.0f; p.addRectangle(-pointerThickness * 0.5f, -radius, pointerThickness, pointerLength); p.applyTransform(juce::AffineTransform::rotation(angle).translated(centreX, centreY)); g.setColour(juce::Colours::white); g.fillPath(p); } ``` > [Slider customisation tutorial](https://docs.juce.com/master/tutorial_look_and_feel_customisation.html#tutorial_look_and_feel_customisation_sliders) ### 成果 ![](https://i.imgur.com/JWaLhT3.png) * 在參考圖中,旋鈕表面的反光形成的立體感,原本打算以漸層來模擬,因此研究了 JUCE 中的 `ColourGradient` class,但研究後發現如參考圖中的漸層較難實現,因此改為使用堆疊層次的方式來呈現立體感 > [ColourGradient docs](https://docs.juce.com/master/classColourGradient.html) ## 實作示波器 ### 簡述 * 定義一個 class:`Oscilloscope` * 其中,需要實作的功能有二 1. 生成波形圖 2. 隨時間更新畫面 ### 生成波形圖 ```cpp= void Oscilloscope::generateWaveform() { juce::AudioBuffer<float> temp; while (waveform.getNumCompleteBuffersAvailable() > 0) { if (waveform.getAudioBuffer(temp)) { auto size = temp.getNumSamples(); juce::FloatVectorOperations::copy( pathBuffer.getWritePointer(0, 0), pathBuffer.getReadPointer(0, size), pathBuffer.getNumSamples() - size ); juce::FloatVectorOperations::copy( pathBuffer.getWritePointer(0, pathBuffer.getNumSamples() - size), temp.getReadPointer(0, 0), size ); pathProducer.generatePath(pathBuffer, getLocalBounds().toFloat()); } } while (pathProducer.getNumPathsAvailable() > 0) { pathProducer.getPath(waveformPath); } } ``` * `waveform` 為一 FIFO buffer,當此 buffer 可用時,便會開始讀取 audio buffer 的內容,暫存至 `temp` 中,而最終目標是將內容存入 `pathBuffer` 中 * 第一個 `while` 迴圈中,有兩次的 `juce::FloatVectorOperations::copy()` 呼叫 * 第一次呼叫,會將 `pathBuffer` 的內容向前平移,以在後方空出所需的空間 * 第二次呼叫,會將 `temp` 中的內容放入前一步所空出的空間 * 至此便將 `pathBuffer` 的內容更新完畢,接下來便可藉由 `pathProducer` 生成波形 * 第二個 `while` 迴圈中,會將 `pathProducer` 生成的波形取出,暫存至 `waveformPath` 變數中 ### 隨時間更新畫面 * 為了讓示波器隨時間更新當前波形,`Oscilloscope` 需要繼承 `juce::Timer` class,並覆寫 `timerCallback()` 函式 ```cpp void Oscilloscope::timerCallback() { generateWaveform(); repaint(); } ``` * 在生成波形後呼叫 `repaint()` 函式,會將示波器標示為需要重新繪製,隨後 `paint()` 函式便會被呼叫 * `paint()` 函式實作如下 ```cpp void Oscilloscope::paint(juce::Graphics &g) { g.fillAll(juce::Colour(50, 50, 50)); g.setColour(juce::Colours::grey); g.drawRoundedRectangle(getLocalBounds().toFloat(), 3, 3); g.setColour(juce::Colours::white); g.strokePath(waveformPath, juce::PathStrokeType(1.f)); } ``` * 在生成波形時,使用了 `waveformPath` 變數來儲存當前的波形,因此,每次呼叫 `paint()` 函式時,當前的波形便會更新到畫面上 * 在 `Oscilloscope` 的 constructor 中需要啟動計時器,並設定呼叫 `timerCallback()` 的時間間隔 ```cpp Oscilloscope::Oscilloscope(MyAudioProcessor &p): audioProcessor(p), waveform(p.getSingleChannelSampleFifo()) { pathBuffer.setSize(1, 1024); startTimerHz(30); } ``` * 此處的 `startTimerHz(30)`,表示每秒有 30 個 cycles,也就是 $1/30$ 秒會呼叫一次 `timerCallback()` ### 成果 ![](https://i.imgur.com/j9h4Fzt.png) ## 實作頻譜圖 ### 簡述 * 定義一個 class:`FrequencySpectrum` * 其中,需要實作的功能有三 1. 繪製網格 2. 生成頻譜 3. 隨時間更新畫面 ### 繪製網格 * 首先,我們看到 `paint()` 函式 ```cpp= void FrequencySpectrum::paint(juce::Graphics &g) { g.fillAll(juce::Colour(50, 50, 50)); g.setColour(juce::Colours::grey); g.drawRoundedRectangle(getLocalBounds().toFloat(), 3, 3); drawBackgroundGrid(g); g.setColour(juce::Colours::white); g.strokePath(spectrumPath, juce::PathStrokeType(1.f)); drawTextLabels(g); } ``` * 與示波器相較之下,在 `paint()` 函式中,額外呼叫了兩個函式:`drawBackgroundGrid()` 與 `drawTextLabels()`,以下將分別說明 * 由於後繪製的圖形會將先繪製的圖形覆蓋,因此第 7-10 行的程式碼順序不可更動,以形成網格在頻譜之下、頻譜在標籤之下的效果,如下圖 ![](https://i.imgur.com/7OAa6UF.png) * `drawBackgroundGrid()` 負責繪製網格,實作如下 ```cpp void FrequencySpectrum::drawBackgroundGrid(juce::Graphics &g) { auto freqs = getFrequencies(); auto gains = getGains(); auto area = getRenderArea(); auto left = area.getX(); auto top = area.getY(); auto right = area.getRight(); auto bottom = area.getBottom(); auto xs = getXs(freqs, getAnalysisArea().getX(), getAnalysisArea().getWidth()); g.setColour(juce::Colours::dimgrey); for (auto x : xs) { g.drawVerticalLine(x, top, bottom); } g.setColour(juce::Colours::darkgrey); for (auto gain : gains) { auto y = juce::jmap( gain, gains.front(), gains.back(), float(getAnalysisArea().getBottom()), float(getAnalysisArea().getY()) ); g.drawHorizontalLine(y, left, right); } } ``` * 透過呼叫 `getRenderArea()`,便能夠取得需要繪製網格的區域 ```cpp juce::Rectangle<int> getRenderArea() { auto bounds = getLocalBounds(); bounds.removeFromTop(3); bounds.removeFromBottom(3); bounds.removeFromLeft(3); bounds.removeFromRight(3); return bounds; } ``` * 四個方位皆減去 3,是為了避免與頻譜圖的外框重疊 * `getAnalysisArea()` 會取得實際繪製頻譜的範圍,也就是 x 座標的 20Hz 到 20kHz,以及 y 座標的 -36dB 到 +12dB 的之間範圍 ```cpp juce::Rectangle<int> getAnalysisArea() { auto bounds = getLocalBounds(); bounds.removeFromTop(14); bounds.removeFromBottom(18); bounds.removeFromLeft(18); bounds.removeFromRight(16); return bounds; } ``` * 網格的線條會繪製在 `getRenderArea()` 所取得的區域上,但線條之間的間隔是以 `getAnalysisArea()` 所取得的區域大小作計算的 * `drawTextLabels()` 負責繪製文字標籤,實作如下 ```cpp void FrequencySpectrum::drawTextLabels(juce::Graphics &g) { const int fontHeight = 10; g.setFont(fontHeight); g.setColour(juce::Colours::lightgrey); auto freqs = getFrequencies(); auto gains = getGains(); auto area = getAnalysisArea(); auto left = area.getX(); auto top = area.getY(); auto bottom = area.getBottom(); auto width = area.getWidth(); auto xs = getXs(freqs, left, width); for (int i = 0; i < freqs.size(); ++i) { auto f = freqs[i]; auto x = xs[i]; bool isThousand = false; if (f > 999.f) { isThousand = true; f /= 1000.f; } juce::String str; str << f; if (isThousand) str << "k"; str << "Hz"; auto textWidth = g.getCurrentFont().getStringWidth(str); juce::Rectangle<int> r; r.setSize(textWidth, fontHeight); r.setCentre(x, 0); r.setY(getRenderArea().getBottom() - fontHeight); g.drawFittedText(str, r, juce::Justification::centred, 1); } for (auto gain : gains) { auto y = juce::jmap(gain, gains.front(), gains.back(), float(bottom), float(top)); juce::String str; if (gain > 0) str << "+"; str << gain << "dB"; auto textWidth = g.getCurrentFont().getStringWidth(str); juce::Rectangle<int> r; r.setSize(textWidth, fontHeight); r.setCentre(0, y - fontHeight / 2.f); r.setX(5); g.drawFittedText(str, r, juce::Justification::centred, 1); } } ``` ### 生成頻譜 ```cpp void FrequencySpectrum::generateSpectrum() { juce::AudioBuffer<float> temp; while (spectrum.getNumCompleteBuffersAvailable() > 0) { if (spectrum.getAudioBuffer(temp)) { auto size = temp.getNumSamples(); juce::FloatVectorOperations::copy( pathBuffer.getWritePointer(0, 0), pathBuffer.getReadPointer(0, size), pathBuffer.getNumSamples() - size ); juce::FloatVectorOperations::copy( pathBuffer.getWritePointer(0, pathBuffer.getNumSamples() - size), temp.getReadPointer(0, 0), size ); fftDataGenerator.produceFFTDataForRendering(pathBuffer, getGains().front()); } } const auto fftsize = fftDataGenerator.getFFTSize(); const auto binWidth = audioProcessor.getSampleRate() / double(fftsize); while (fftDataGenerator.getNumAvailableFFTDataBlocks() > 0) { std::vector<float> fftData; if(fftDataGenerator.getFFTData(fftData)) { pathProducer.generatePath(fftData, getAnalysisArea().toFloat(), fftsize, binWidth, getGains().front()); } } while (pathProducer.getNumPathsAvailable() > 0) { pathProducer.getPath(spectrumPath); } } ``` * 經由觀察可以發現,此實作與示波器的實作大同小異,相同的部分可參考[該段落](#生成波形圖)的說明 * 不同點在於,`pathBuffer` 更新完畢後,是先透過 `fftDataGenerator` 產生 `fftData` 的,生成的 `fftData` 後續才會交由 `pathProducer` 來生成頻譜 ### 隨時間更新畫面 此功能與前述示波器實作的段落中,概念與做法皆相同,詳見[這裡](#隨時間更新畫面) ### 成果 ![](https://i.imgur.com/kdumTuH.png) --- ## 問題與解法 ### `dsp` module * `FIFO.h` 中使用到 `dsp` module 的部分在建置時會報錯,如下圖 ![](https://i.imgur.com/fo6eT4T.png) * 由錯誤訊息可知專案沒有讀取到該 module * 檢查後發現該 module 原本並不包含在專案的 module 列表中,只要自行手動加入即可,如下圖 ![](https://i.imgur.com/GVzxBri.png)