BogaudioModules

BogaudioModules for VCV Rack
Log | Files | Refs | README | LICENSE

commit 9e186334a1ea8b3156d8d86632fe173112a295df
parent b3bc26a76ed048a2414b969c0dd6dff56160affb
Author: Matt Demanett <matt@demanett.net>
Date:   Tue, 22 Sep 2020 23:09:44 -0400

RANALYZER: various fixes; add windowing to test signal. #116

Diffstat:
MREADME-prerelease.md | 8+++++---
Msrc/Ranalyzer.cpp | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/Ranalyzer.hpp | 20++++++++++++++++++--
Msrc/analyzer_base.cpp | 6++++--
Msrc/analyzer_base.hpp | 20+++++++++++++++-----
Msrc/dsp/analyzer.cpp | 19+++++++++++++++++++
Msrc/dsp/analyzer.hpp | 10+++++++---
Msrc/dsp/oscillator.cpp | 19++++++++++---------
Mtest/plot.cpp | 25+++++++++++++++----------
9 files changed, 197 insertions(+), 42 deletions(-)

diff --git a/README-prerelease.md b/README-prerelease.md @@ -961,13 +961,15 @@ RANALYZER is a frequency response analyzer: it passes a test signal to another m **This module is primarily useful to plugin developers**, especially when developing filters. Of course anyone may use it, to investigate the response of some module, or to tune a filter bank, or what have you. -By default, will produce a one-shot test signal, emitted at SEND, upon receipt of a trigger (manual or CV) at the TRIG inputs. The duration of the test signal is always 16384 samples (32768 if Rack's sample rate is 192K or higher) -- the duration in time will depend on Rack's current sample rate. The same number of samples is collected from RETURN, if it is patched. Both signals are converted to the frequency domain and displayed (test in green, response in magenta), along with an analysis trace (orange), which shows the difference between the response and the test signals, in the frequency domain, in decibels. The context-menu option "Display traces" controls which of the traces are displayed. +By default, will produce a one-shot test signal, emitted at SEND, upon receipt of a trigger (manual or CV) at the TRIG inputs. The duration of the test signal is always 16384 samples (32768 if Rack's sample rate is 96K or higher) -- the duration in time will depend on Rack's current sample rate. The same number of samples is collected from RETURN, if it is patched. Both signals are converted to the frequency domain and displayed (test in green, response in magenta), along with an analysis trace (orange), which shows the difference between the response and the test signals, in the frequency domain, in decibels. The context-menu option "Display traces" controls which of the traces are displayed. -The test signal is a swept sine wave (or "chirp", see <a href="#chirp">CHIRP</a>), with an exponential (if the EXP toggle is on) or linear sweep. The start and end frequencies for sine sweep are set by the FREQ1 and FREQ2 knobs, each with a range in hertz from 1 to the Nyquist rate (half the sampling rate); if FREQ1 is less than FREQ2 the sweep is upwards in frequency, downwards otherwise. +The test signal is a swept sine wave (or "chirp", see <a href="#chirp">CHIRP</a>), with an exponential (if the EXP toggle is on) or linear sweep. The start and end frequencies for sine sweep are set by the FREQ1 and FREQ2 knobs, each with a range in hertz from 1 to a bit less than the Nyquist rate (half the sampling rate); if FREQ1 is less than FREQ2 the sweep is upwards in frequency, downwards otherwise. Patching an input to TEST overrides the swept sine generator; the TEST input is used as the test signal. -If the LOOP toggle is enabled, the module continuously outputs, collects and displays signals, in 16384-sample blocks. Regardless of whether the collection cycle is triggered or looped, a pulse is emitted at the TRIG output when the cycle begins, and at the EOC output when it ends. +A window function is optionally applied to the test signal (whether the internally-generated one, or one being read from TEST) before it is emitted at SEND and before frequency domain conversion for the display. The default "taper" window type applies a brief fade in and fade out to the test signal; this cleans up noise that otherwise shows up in the displayed plots, as an artifact of frequency domain conversion. It also slightly distorts the test signal. The window can be disabled on the context window (or set to another type -- Hamming or Kaiser -- provided mostly as a curiosity). + +If the LOOP toggle is enabled, the module continuously outputs, collects and displays the test, response and analysis signals. Regardless of whether the collection cycle is triggered or looped, a pulse is emitted at the TRIG output when the cycle begins, and at the EOC output when it ends. The R. DELAY (response delay) control allows sample-accurate alignment of the test and response signals for analysis. When SEND is patched directly to the module to be analyzed, and that module's output is patched directly back to RETURN, and the analyzed module does not impose an internal sample delay, then the returned signal will be received by RANALYZER two samples later, relative to the test sample RANALYZER emits. More complicated patches between SEND and RETURN, or modules under test which have internal sample delays, can increase this, in which case R. DELAY may be set to whatever this actual sample delay is. In practice, getting this right will likely not be very important. diff --git a/src/Ranalyzer.cpp b/src/Ranalyzer.cpp @@ -3,8 +3,14 @@ #define TRIGGER_ON_LOAD "triggerOnLoad" #define DISPLAY_TRACES "display_traces" +#define DISPLAY_TRACES_ALL "all" #define DISPLAY_TRACES_TEST_RETURN "test_return" #define DISPLAY_TRACES_ANALYSIS "analysis" +#define WINDOW_TYPE "window_type" +#define WINDOW_TYPE_NONE "none" +#define WINDOW_TYPE_TAPER "taper" +#define WINDOW_TYPE_HAMMING "hamming" +#define WINDOW_TYPE_KAISER "Kaiser" void Ranalyzer::reset() { _trigger.reset(); @@ -27,6 +33,10 @@ void Ranalyzer::sampleRateChange() { else { _core.setParams(1, AnalyzerCore::QUALITY_FIXED_16K, AnalyzerCore::WINDOW_NONE); } + setWindow(_windowType); + if (!_run && !_initialDelay) { + _initialDelay = new Timer(_sampleRate, initialDelaySeconds); + } } json_t* Ranalyzer::toJson(json_t* root) { @@ -36,7 +46,10 @@ json_t* Ranalyzer::toJson(json_t* root) { json_object_set_new(root, TRIGGER_ON_LOAD, json_boolean(_triggerOnLoad)); switch (_displayTraces) { - case ALL_TRACES: break; + case ALL_TRACES: { + json_object_set_new(root, DISPLAY_TRACES, json_string(DISPLAY_TRACES_ALL)); + break; + } case TEST_RETURN_TRACES: { json_object_set_new(root, DISPLAY_TRACES, json_string(DISPLAY_TRACES_TEST_RETURN)); break; @@ -46,6 +59,26 @@ json_t* Ranalyzer::toJson(json_t* root) { break; } } + + switch (_windowType) { + case NONE_WINDOW_TYPE: { + json_object_set_new(root, WINDOW_TYPE, json_string(WINDOW_TYPE_NONE)); + break; + } + case TAPER_WINDOW_TYPE: { + json_object_set_new(root, WINDOW_TYPE, json_string(WINDOW_TYPE_TAPER)); + break; + } + case HAMMING_WINDOW_TYPE: { + json_object_set_new(root, WINDOW_TYPE, json_string(WINDOW_TYPE_HAMMING)); + break; + } + case KAISER_WINDOW_TYPE: { + json_object_set_new(root, WINDOW_TYPE, json_string(WINDOW_TYPE_KAISER)); + break; + } + } + return root; } @@ -62,13 +95,33 @@ void Ranalyzer::fromJson(json_t* root) { json_t* dt = json_object_get(root, DISPLAY_TRACES); if (dt) { std::string dts = json_string_value(dt); - if (dts == DISPLAY_TRACES_TEST_RETURN) { + if (dts == DISPLAY_TRACES_ALL) { + setDisplayTraces(ALL_TRACES); + } + else if (dts == DISPLAY_TRACES_TEST_RETURN) { setDisplayTraces(TEST_RETURN_TRACES); } else if (dts == DISPLAY_TRACES_ANALYSIS) { setDisplayTraces(ANALYSIS_TRACES); } } + + json_t* wt = json_object_get(root, WINDOW_TYPE); + if (wt) { + std::string wts = json_string_value(wt); + if (wts == WINDOW_TYPE_NONE) { + setWindow(NONE_WINDOW_TYPE); + } + else if (wts == WINDOW_TYPE_TAPER) { + setWindow(TAPER_WINDOW_TYPE); + } + else if (wts == WINDOW_TYPE_HAMMING) { + setWindow(HAMMING_WINDOW_TYPE); + } + else if (wts == WINDOW_TYPE_KAISER) { + setWindow(KAISER_WINDOW_TYPE); + } + } } void Ranalyzer::modulate() { @@ -110,19 +163,26 @@ void Ranalyzer::processAll(const ProcessArgs& args) { _run = true; _bufferCount = _currentReturnSampleDelay = _returnSampleDelay; _chirp.reset(); - _chirp.setParams(_frequency1, _frequency2, (double)_core.size() / (double)_sampleRate, !_exponential); + _cycleN = _core.size(); + _cycleI = 0; + _chirp.setParams(_frequency1, _frequency2, _core.size() / (double)_sampleRate, !_exponential); _triggerPulseGen.trigger(0.001f); + _useTestInput = inputs[TEST_INPUT].isConnected(); } } float out = 0.0f; if (_run) { - if (inputs[TEST_INPUT].isConnected()) { + if (_useTestInput) { out = inputs[TEST_INPUT].getVoltage(); } else { - out = _chirp.next() * 10.0f; + out = _chirp.next() * 5.0f; + } + if (_window) { + out *= _window->at(_cycleI); } + _inputBuffer.push(out); if (_bufferCount > 0) { --_bufferCount; @@ -132,14 +192,15 @@ void Ranalyzer::processAll(const ProcessArgs& args) { _core.stepChannelSample(1, inputs[RETURN_INPUT].getVoltage()); } - if (_chirp.isCycleComplete()) { + ++_cycleI; + if (_cycleI >= _cycleN) { _run = false; _flush = true; _bufferCount = _currentReturnSampleDelay; } } else if (_flush) { - _core.stepChannelSample(0, _inputBuffer.value(_currentReturnSampleDelay - 1)); + _core.stepChannelSample(0, _inputBuffer.value((_run ? _currentReturnSampleDelay : _bufferCount) - 1)); _core.stepChannelSample(1, inputs[RETURN_INPUT].getVoltage()); --_bufferCount; if (_bufferCount < 1) { @@ -177,6 +238,33 @@ void Ranalyzer::setChannelDisplayListener(ChannelDisplayListener* listener) { _channelDisplayListener = listener; } +void Ranalyzer::setWindow(WindowType wt) { + if (!_window || _windowType != wt || _window->size() != _core.size()) { + if (_window) { + delete _window; + _window = NULL; + } + + _windowType = wt; + switch (_windowType) { + case NONE_WINDOW_TYPE: break; + case TAPER_WINDOW_TYPE: { + _window = new PlanckTaperWindow(_core.size(), (int)(_core.size() * 0.03f)); + break; + } + case HAMMING_WINDOW_TYPE: { + _window = new HammingWindow(_core.size()); + break; + } + case KAISER_WINDOW_TYPE: { + _window = new KaiserWindow(_core.size()); + break; + } + } + } +} + + struct AnalysisBinsReader : AnalyzerDisplay::BinsReader { AnalysisBinsReader(Ranalyzer* module) : AnalyzerDisplay::BinsReader(module) {} @@ -320,9 +408,17 @@ struct RanalyzerWidget : AnalyzerBaseWidget { mi->addItem(OptionMenuItem("Test/return only", [a]() { return a->_displayTraces == Ranalyzer::TEST_RETURN_TRACES; }, [a]() { a->setDisplayTraces(Ranalyzer::TEST_RETURN_TRACES); })); OptionsMenuItem::addToMenu(mi, menu); } + { + OptionsMenuItem* mi = new OptionsMenuItem("Window"); + mi->addItem(OptionMenuItem("None", [a]() { return a->_windowType == Ranalyzer::NONE_WINDOW_TYPE; }, [a]() { a->setWindow(Ranalyzer::NONE_WINDOW_TYPE); })); + mi->addItem(OptionMenuItem("Taper", [a]() { return a->_windowType == Ranalyzer::TAPER_WINDOW_TYPE; }, [a]() { a->setWindow(Ranalyzer::TAPER_WINDOW_TYPE); })); + mi->addItem(OptionMenuItem("Hamming", [a]() { return a->_windowType == Ranalyzer::HAMMING_WINDOW_TYPE; }, [a]() { a->setWindow(Ranalyzer::HAMMING_WINDOW_TYPE); })); + mi->addItem(OptionMenuItem("Kaiser", [a]() { return a->_windowType == Ranalyzer::KAISER_WINDOW_TYPE; }, [a]() { a->setWindow(Ranalyzer::KAISER_WINDOW_TYPE); })); + OptionsMenuItem::addToMenu(mi, menu); + } addFrequencyPlotContextMenu(menu); addFrequencyRangeContextMenu(menu); - addAmplitudePlotContextMenu(menu); + addAmplitudePlotContextMenu(menu, false); menu->addChild(new BoolOptionMenuItem("Trigger on load", [a]() { return &a->_triggerOnLoad; })); } }; diff --git a/src/Ranalyzer.hpp b/src/Ranalyzer.hpp @@ -46,9 +46,17 @@ struct Ranalyzer : AnalyzerBase { ANALYSIS_TRACES }; + enum WindowType { + NONE_WINDOW_TYPE, + TAPER_WINDOW_TYPE, + HAMMING_WINDOW_TYPE, + KAISER_WINDOW_TYPE + }; + static constexpr float minFrequency = 1.0f; static constexpr float maxFrequencyNyquistRatio = 0.49f; static constexpr int maxResponseDelay = 20; + static constexpr float initialDelaySeconds = 0.01f; struct FrequencyParamQuantity : ParamQuantity { float getDisplayValue() override { @@ -92,13 +100,18 @@ struct Ranalyzer : AnalyzerBase { int _currentReturnSampleDelay = 0; int _bufferCount = 0; HistoryBuffer<float> _inputBuffer; + int _cycleI = 0; + int _cycleN = 0; + bool _useTestInput = false; Traces _displayTraces = ALL_TRACES; ChannelDisplayListener* _channelDisplayListener = NULL; bool _triggerOnLoad = true; Timer* _initialDelay = NULL; + WindowType _windowType = TAPER_WINDOW_TYPE; + bogaudio::dsp::Window* _window = NULL; Ranalyzer() - : AnalyzerBase(3, NUM_PARAMS, NUM_INPUTS, NUM_OUTPUTS) + : AnalyzerBase(3, NUM_PARAMS, NUM_INPUTS, NUM_OUTPUTS, 0, SpectrumAnalyzer::OVERLAP_1) , _inputBuffer(maxResponseDelay, 0.0f) { configParam<FrequencyParamQuantity>(FREQUENCY1_PARAM, 0.0f, 1.0f, 0.0f, "Frequency 1", " Hz"); @@ -109,12 +122,14 @@ struct Ranalyzer : AnalyzerBase { configParam(DELAY_PARAM, 2.0f, (float)maxResponseDelay, 2.0f, "Return sample delay"); _skinnable = false; - _initialDelay = new Timer(APP->engine->getSampleRate(), 0.01f); } virtual ~Ranalyzer() { if (_initialDelay) { delete _initialDelay; } + if (_window) { + delete _window; + } } void reset() override; @@ -125,6 +140,7 @@ struct Ranalyzer : AnalyzerBase { void processAll(const ProcessArgs& args) override; void setDisplayTraces(Traces traces); void setChannelDisplayListener(ChannelDisplayListener* listener); + void setWindow(WindowType wt); }; } // namespace bogaudio diff --git a/src/analyzer_base.cpp b/src/analyzer_base.cpp @@ -353,14 +353,16 @@ void AnalyzerBaseWidget::addFrequencyRangeContextMenu(Menu* menu) { OptionsMenuItem::addToMenu(mi, menu); } -void AnalyzerBaseWidget::addAmplitudePlotContextMenu(Menu* menu) { +void AnalyzerBaseWidget::addAmplitudePlotContextMenu(Menu* menu, bool linearOption) { auto m = dynamic_cast<AnalyzerBase*>(module); assert(m); OptionsMenuItem* mi = new OptionsMenuItem("Amplitude plot"); mi->addItem(OptionMenuItem("Decibels to -60dB", [m]() { return m->_amplitudePlot == AnalyzerBase::DECIBELS_80_AP; }, [m]() { m->_amplitudePlot = AnalyzerBase::DECIBELS_80_AP; })); mi->addItem(OptionMenuItem("Decibels To -120dB", [m]() { return m->_amplitudePlot == AnalyzerBase::DECIBELS_140_AP; }, [m]() { m->_amplitudePlot = AnalyzerBase::DECIBELS_140_AP; })); - mi->addItem(OptionMenuItem("Linear percentage", [m]() { return m->_amplitudePlot == AnalyzerBase::PERCENTAGE_AP; }, [m]() { m->_amplitudePlot = AnalyzerBase::PERCENTAGE_AP; })); + if (linearOption) { + mi->addItem(OptionMenuItem("Linear percentage", [m]() { return m->_amplitudePlot == AnalyzerBase::PERCENTAGE_AP; }, [m]() { m->_amplitudePlot = AnalyzerBase::PERCENTAGE_AP; })); + } OptionsMenuItem::addToMenu(mi, menu); } diff --git a/src/analyzer_base.hpp b/src/analyzer_base.hpp @@ -53,7 +53,7 @@ struct ChannelAnalyzer { , _averagedBins(averageN == 1 ? NULL : new AveragingBuffer<float>(_binsN, averageN)) , _stepBufN(size / overlap) , _stepBuf(new float[_stepBufN] {}) - , _workerBufN(size) + , _workerBufN(size + 1) , _workerBuf(new float[_workerBufN] {}) , _worker(&ChannelAnalyzer::work, this) { @@ -93,14 +93,15 @@ struct AnalyzerCore { int _averageN = 1; Quality _quality = QUALITY_GOOD; Window _window = WINDOW_KAISER; - const SpectrumAnalyzer::Overlap _overlap = SpectrumAnalyzer::OVERLAP_2; + SpectrumAnalyzer::Overlap _overlap = SpectrumAnalyzer::OVERLAP_2; std::mutex _channelsMutex; - AnalyzerCore(int nChannels) + AnalyzerCore(int nChannels, SpectrumAnalyzer::Overlap overlap = SpectrumAnalyzer::OVERLAP_2) : _nChannels(nChannels) , _channels(new ChannelAnalyzer*[_nChannels] {}) , _outBufs(new float[2 * nChannels * _outBufferN] {}) , _currentOutBufs(new std::atomic<float*>[nChannels]) + , _overlap(overlap) { for (int i = 0; i < nChannels; ++i) { _currentOutBufs[i] = _outBufs + 2 * i * _outBufferN; @@ -147,7 +148,16 @@ struct AnalyzerBase : BGModule, AnalyzerTypes { AmplitudePlot _amplitudePlot = DECIBELS_80_AP; AnalyzerCore _core; - AnalyzerBase(int nChannels, int np, int ni, int no, int nl = 0) : _core(nChannels) { + AnalyzerBase( + int nChannels, + int np, + int ni, + int no, + int nl = 0, + SpectrumAnalyzer::Overlap overlap = SpectrumAnalyzer::OVERLAP_2 + ) + : _core(nChannels, overlap) + { config(np, ni, no, nl); } @@ -162,7 +172,7 @@ struct AnalyzerBase : BGModule, AnalyzerTypes { struct AnalyzerBaseWidget : BGModuleWidget { void addFrequencyPlotContextMenu(Menu* menu); void addFrequencyRangeContextMenu(Menu* menu); - void addAmplitudePlotContextMenu(Menu* menu); + void addAmplitudePlotContextMenu(Menu* menu, bool linearOption = true); }; struct AnalyzerDisplay : TransparentWidget, AnalyzerTypes { diff --git a/src/dsp/analyzer.cpp b/src/dsp/analyzer.cpp @@ -57,6 +57,25 @@ float KaiserWindow::i0(float x) { } +PlanckTaperWindow::PlanckTaperWindow(int size, int taperSamples) : Window(size) { + _window[0] = 0.0f; + _sum += _window[size - 1] = 1.0f; + + for (int i = 1; i < taperSamples; ++i) { + float x = ((float)taperSamples / (float)i) - ((float)taperSamples / (float)(taperSamples - i)); + x = 1.0f + exp(x); + x = 1.0f / x; + _sum += _window[i] = x; + } + int nOnes = size - 2 * taperSamples; + std::fill_n(_window + taperSamples, nOnes, 1.0f); + _sum += nOnes; + for (int i = 0; i < taperSamples; ++i) { + _sum += _window[size - 1 - i] = _window[i]; + } +} + + typedef ffft::FFTRealFixLen<10> FIXED_FFT1024; FFT1024::FFT1024() { diff --git a/src/dsp/analyzer.hpp b/src/dsp/analyzer.hpp @@ -24,9 +24,9 @@ struct Window { delete[] _window; } - inline float sum() { - return _sum; - } + inline int size() { return _size; } + inline float at(int i) { assert(i >= 0 && i < _size); return _window[i]; } + inline float sum() { return _sum; } void apply(float* in, float* out); }; @@ -44,6 +44,10 @@ struct KaiserWindow : Window { float i0(float x); }; +struct PlanckTaperWindow : Window { + PlanckTaperWindow(int size, int taperSamples); +}; + struct FFT1024 { void* _fft = NULL; FFT1024(); diff --git a/src/dsp/oscillator.cpp b/src/dsp/oscillator.cpp @@ -385,6 +385,15 @@ void PureChirpOscillator::update() { } float PureChirpOscillator::_next() { + // formulas from https://en.wikipedia.org/wiki/Chirp + double phase = 0.0f; + if (_linear) { + phase = 2.0 * M_PI * (0.5 * _c * (double)(_time * _time) + (double)(_f1 * _time)); + } + else { + phase = 2.0 * M_PI * (double)_f1 * ((pow(_k, (double)_time) - 1.0) * _invlogk); + } + _complete = false; if (_Time - _time < _sampleTime) { _time = 0.0f; @@ -394,15 +403,7 @@ float PureChirpOscillator::_next() { _time += _sampleTime; } - // formulas from https://en.wikipedia.org/wiki/Chirp - float phase = 0.0f; - if (_linear) { - phase = 2.0 * M_PI * (0.5 * _c * (double)(_time * _time) + (double)(_f1 * _time)); - } - else { - phase = 2.0 * M_PI * (double)_f1 * ((pow(_k, (double)_time) - 1.0) * _invlogk); - } - return sinf(phase); + return sin(phase); } void PureChirpOscillator::reset() { diff --git a/test/plot.cpp b/test/plot.cpp @@ -55,20 +55,25 @@ using namespace bogaudio::dsp; // return 0; // } -const float sr = 10000.0f; -float f1 = 100.0f; -float f2 = sr / 2.0f * 0.1f; -float T = 1.5f; -float y(float t) { - float k = powf(f2 / f1, 1.0f / T); - return sinf(2.0f * M_PI * f1 * ((powf(k, t) - 1.0f) / logf(k))); +// const float sr = 10000.0f; +// float f1 = 100.0f; +// float f2 = sr / 2.0f * 0.1f; +// float T = 1.5f; +// float y(float t) { +// float k = powf(f2 / f1, 1.0f / T); +// return sinf(2.0f * M_PI * f1 * ((powf(k, t) - 1.0f) / logf(k))); +// } + +PlanckTaperWindow w(1024, 300); +float y(float x) { + return w._window[(int)x]; } int main() { const float xMin = 0.0f; - // const float xMax = 1023.0f; - const float xMax = T; - const float samples = sr; + const float xMax = 1023.0f; + // const float xMax = T; + const float samples = 1024.0f; const float delta = (xMax - xMin) / samples; float x = xMin;