diff --git a/cmake/modules/PluginList.cmake b/cmake/modules/PluginList.cmake index 8b26d4ed530..009679533ae 100644 --- a/cmake/modules/PluginList.cmake +++ b/cmake/modules/PluginList.cmake @@ -38,6 +38,7 @@ SET(LMMS_PLUGIN_LIST DynamicsProcessor Eq Flanger + GranularPitchShifter HydrogenImport LadspaBrowser LadspaEffect diff --git a/include/lmms_constants.h b/include/lmms_constants.h index c6452d6c619..0c2ee175351 100644 --- a/include/lmms_constants.h +++ b/include/lmms_constants.h @@ -36,6 +36,7 @@ constexpr long double LD_PI_R = 1.0 / LD_PI; constexpr long double LD_PI_SQR = LD_PI * LD_PI; constexpr long double LD_E = 2.71828182845904523536028747135266249775724709369995; constexpr long double LD_E_R = 1.0 / LD_E; +constexpr long double LD_SQRT_2 = 1.41421356237309504880168872420969807856967187537695; constexpr double D_PI = (double) LD_PI; constexpr double D_2PI = (double) LD_2PI; @@ -44,6 +45,7 @@ constexpr double D_PI_R = (double) LD_PI_R; constexpr double D_PI_SQR = (double) LD_PI_SQR; constexpr double D_E = (double) LD_E; constexpr double D_E_R = (double) LD_E_R; +constexpr double D_SQRT_2 = (double) LD_SQRT_2; constexpr float F_PI = (float) LD_PI; constexpr float F_2PI = (float) LD_2PI; @@ -52,6 +54,7 @@ constexpr float F_PI_R = (float) LD_PI_R; constexpr float F_PI_SQR = (float) LD_PI_SQR; constexpr float F_E = (float) LD_E; constexpr float F_E_R = (float) LD_E_R; +constexpr float F_SQRT_2 = (float) LD_SQRT_2; // Microtuner constexpr unsigned int MaxScaleCount = 10; //!< number of scales per project diff --git a/plugins/GranularPitchShifter/CMakeLists.txt b/plugins/GranularPitchShifter/CMakeLists.txt new file mode 100755 index 00000000000..c8f70fc7882 --- /dev/null +++ b/plugins/GranularPitchShifter/CMakeLists.txt @@ -0,0 +1,9 @@ +include(BuildPlugin) +build_plugin(granularpitchshifter + GranularPitchShifterEffect.cpp + GranularPitchShifterControls.cpp + GranularPitchShifterControlDialog.cpp + MOCFILES + GranularPitchShifterControls.h + GranularPitchShifterControlDialog.h + EMBEDDED_RESOURCES *.png) diff --git a/plugins/GranularPitchShifter/GranularPitchShifterControlDialog.cpp b/plugins/GranularPitchShifter/GranularPitchShifterControlDialog.cpp new file mode 100755 index 00000000000..71a8d15f716 --- /dev/null +++ b/plugins/GranularPitchShifter/GranularPitchShifterControlDialog.cpp @@ -0,0 +1,158 @@ +/* + * GranularPitchShifterControlDialog.cpp + * + * Copyright (c) 2024 Lost Robot + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "GranularPitchShifterControlDialog.h" +#include "GranularPitchShifterControls.h" +#include "embed.h" +#include "LcdFloatSpinBox.h" +#include "Knob.h" +#include "GuiApplication.h" +#include "gui_templates.h" +#include "PixmapButton.h" + + +namespace lmms::gui +{ + +GranularPitchShifterControlDialog::GranularPitchShifterControlDialog(GranularPitchShifterControls* controls) : + EffectControlDialog(controls) +{ + setAutoFillBackground(true); + QPalette pal; + pal.setBrush(backgroundRole(), PLUGIN_NAME::getIconPixmap("artwork")); + setPalette(pal); + setFixedSize(305, 180); + + auto makeKnob = [this](KnobType style, int x, int y, const QString& hintText, const QString& unit, FloatModel* model) + { + Knob* newKnob = new Knob(style, this); + newKnob->move(x, y); + newKnob->setModel(model); + newKnob->setHintText(hintText, unit); + return newKnob; + }; + + makeKnob(KnobType::Bright26, 19, 78, tr("Grain Size:"), " Hz", &controls->m_sizeModel); + makeKnob(KnobType::Bright26, 116, 10, tr("Spray:"), " seconds", &controls->m_sprayModel); + makeKnob(KnobType::Bright26, 158, 10, tr("Jitter:"), " octaves", &controls->m_jitterModel); + makeKnob(KnobType::Bright26, 200, 10, tr("Twitch:"), " octaves", &controls->m_twitchModel); + makeKnob(KnobType::Bright26, 188, 60, tr("Spray Stereo Spread:"), "", &controls->m_spraySpreadModel); + makeKnob(KnobType::Bright26, 135, 110, tr("Grain Shape:"), "", &controls->m_shapeModel); + makeKnob(KnobType::Bright26, 188, 110, tr("Fade Length:"), "", &controls->m_fadeLengthModel); + makeKnob(KnobType::Bright26, 258, 45, tr("Feedback:"), "", &controls->m_feedbackModel); + makeKnob(KnobType::Bright26, 258, 92, tr("Minimum Allowed Latency:"), " seconds", &controls->m_minLatencyModel); + makeKnob(KnobType::Small17, 66, 157, tr("Density:"), "x", &controls->m_densityModel); + makeKnob(KnobType::Small17, 8, 157, tr("Glide:"), " seconds", &controls->m_glideModel); + + LcdFloatSpinBox* pitchBox = new LcdFloatSpinBox(3, 2, "11green", tr("Pitch"), this); + pitchBox->move(15, 41); + pitchBox->setModel(&controls->m_pitchModel); + pitchBox->setToolTip(tr("Pitch")); + pitchBox->setSeamless(true, true); + + LcdFloatSpinBox* pitchSpreadBox = new LcdFloatSpinBox(3, 2, "11green", tr("Pitch Stereo Spread"), this); + pitchSpreadBox->move(133, 66); + pitchSpreadBox->setModel(&controls->m_pitchSpreadModel); + pitchSpreadBox->setToolTip(tr("Pitch Stereo Spread")); + pitchSpreadBox->setSeamless(true, true); + + QPushButton button("Show Help", this); + connect(&button, &QPushButton::clicked, this, &GranularPitchShifterControlDialog::showHelpWindow); + + PixmapButton* m_helpBtn = new PixmapButton(this, nullptr); + m_helpBtn->move(278, 159); + m_helpBtn->setActiveGraphic(PLUGIN_NAME::getIconPixmap("help_active")); + m_helpBtn->setInactiveGraphic(PLUGIN_NAME::getIconPixmap("help_inactive")); + m_helpBtn->setToolTip(tr("Open help window")); + connect(m_helpBtn, SIGNAL(clicked()), this, SLOT(showHelpWindow())); + + PixmapButton* prefilterButton = new PixmapButton(this, tr("Prefilter")); + prefilterButton->move(8, 133); + prefilterButton->setActiveGraphic(PLUGIN_NAME::getIconPixmap("prefilter_active")); + prefilterButton->setInactiveGraphic(PLUGIN_NAME::getIconPixmap("prefilter_inactive")); + prefilterButton->setCheckable(true); + prefilterButton->setModel(&controls->m_prefilterModel); + prefilterButton->setToolTip(tr("Prefilter")); + + ComboBox* rangeBox = new ComboBox(this); + rangeBox->setGeometry(189, 155, 80, 22); + rangeBox->setModel(&controls->m_rangeModel); + controls->updateRange(); +} + +void GranularPitchShifterControlDialog::showHelpWindow() { + GranularPitchShifterHelpView::getInstance()->close(); + GranularPitchShifterHelpView::getInstance()->show(); +} + + +QString GranularPitchShifterHelpView::s_helpText= +"
" +"Granular Pitch Shifter

" +"Plugin by Lost Robot
" +"GUI by thismoon
" +"
" +"

Grain:

" +"Pitch - The amount of pitch shifting to perform, in 12EDO semitones.
" +"Size - The length of each grain, in Hz. By default, new grains will be created at double this rate.
In most cases, you'll want this to be set to higher frequencies when shifting the pitch upward, and vice-versa.
" +"

Random:

" +"Spray - The amount of randomization for the playback position of each grain, in seconds.
This does not change when the grain plays, but rather what audio the grain is pulling from.
For example, a value of 0.5 seconds will allow each grain to play back audio from up to half of a second ago.
It's oftentimes recommended to use at least a small amount of Spray, as this will break up the periodicity in the grains, which is usually the main artifact caused by a granular pitch shifter.
This will also make the grains uncorrelated with each other, guaranteeing that a grain Shape value of 2 will always be optimal.
" +"Jitter - The amount of randomization for the pitch of each grain, in octaves.
This does not impact how often grains are created.
" +"Twitch - The amount of randomization for how often new grains are created, in octaves.
Jitter and Twitch both use the same random numbers, so if they're at the same value, then the grain creation timings will be changed exactly proportionally to their change in pitch.
" +"

Stereo:

" +"Pitch - The total distance in pitch between both stereo channels, in 12EDO semitones.
Half of the amount of pitch shifting shown will be applied to the right channel, and the opposite to the left channel.
" +"Spray - The allowed distance between each channel's randomized position with the Spray feature in the Random category.
A value of 1 makes the Spray values in each channel entirely unlinked." +"

Shape:

" +"Shape - The shape of each grain's fades. In most cases, 2 is the optimal value, providing equal-power fades.
However, when the plugin is performing minimal pitch shifting and has most of its parameters at default, a value of 1 may be more optimal, providing equal-gain fades.
All fades are designed for 50% grain overlap.
" +"Fade - The length of the grain fades. A value of 1 provides the cleanest fades, causing those fades to reach across the entire grain.
Values below 1 make the fade artifacts more audible, but those fades will only apply to the outer edges of each grain.
A value of 0 will result in clicking sounds due to the fades no longer being present.
" +"

Delay:

" +"Feedback - The amount of feedback for the pitch shifter.
This feeds a portion of the pitch shifter output back into the input buffer. Large values can be dangerous.
" +"Latency - The minimum amount of latency the pitch shifter will have.
This granular pitch shifter dynamically changes its latency to be at the minimum possible amount depending on your settings.
If you'd like for this latency to be more predictable, you may increase the value of this parameter until the latency no longer changes.
This parameter may also be used to be set the minimum amount of delay for the feedback.
A larger latency amount can remove subtle fluttering artifacts that may result from automating the pitch shifting amount at high speeds." +"

Miscellaneous:

" +"Prefilter - Enables a 12 dB lowpass filter prior to the pitch shifting which automatically adjusts its cutoff to drastically reduce any resulting aliasing.
" +"Density - The multiplier for how often grains are spawned.
This will increase the grain overlap above 50%.
It will create painful piercing sounds if you don't make use of any of the knobs in the Random category.
Otherwise, you can get some interesting effects similar to unison or a stationary Paulstretch.
Note that this knob uses by far the most CPU out of any parameter in this plugin when increased.
" +"Glide - The length of interpolation for the amount of pitch shifting.
A small amount of glide is very effective for cleaning up many of the artifacts that may result from changing the pitch shift amount over time.
" +"Range - The length of the pitch shifter's internal ring buffer.
Changing this will change the minimum and maximum values for some of the other parameters, which are listed in each of the options.
Increase it if you need parameter values that aren't supported with the minimum buffer length. Otherwise, it's best to leave it at its minimum value.
" +; + +GranularPitchShifterHelpView::GranularPitchShifterHelpView():QTextEdit(s_helpText) +{ +#if (QT_VERSION < QT_VERSION_CHECK(5,12,0)) + // Bug workaround: https://codereview.qt-project.org/c/qt/qtbase/+/225348 + using ::operator|; +#endif + setWindowTitle("Granular Pitch Shifter Help"); + setTextInteractionFlags(Qt::TextSelectableByKeyboard | Qt::TextSelectableByMouse); + getGUI()->mainWindow()->addWindowedWidget(this); + parentWidget()->setAttribute(Qt::WA_DeleteOnClose, false); + parentWidget()->setWindowIcon(PLUGIN_NAME::getIconPixmap("logo")); + + // No maximize button + Qt::WindowFlags flags = parentWidget()->windowFlags(); + flags &= ~Qt::WindowMaximizeButtonHint; + parentWidget()->setWindowFlags(flags); +} + + +} // namespace lmms::gui diff --git a/plugins/GranularPitchShifter/GranularPitchShifterControlDialog.h b/plugins/GranularPitchShifter/GranularPitchShifterControlDialog.h new file mode 100755 index 00000000000..751106b2ce2 --- /dev/null +++ b/plugins/GranularPitchShifter/GranularPitchShifterControlDialog.h @@ -0,0 +1,75 @@ +/* + * GranularPitchShifterControlDialog.h + * + * Copyright (c) 2024 Lost Robot + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_GRANULAR_PITCH_SHIFTER_CONTROLS_H +#define LMMS_GRANULAR_PITCH_SHIFTER_CONTROLS_H + +#include "EffectControlDialog.h" + +#include +#include "ComboBox.h" +#include "GuiApplication.h" +#include "MainWindow.h" + +namespace lmms +{ + +class GranularPitchShifterControls; +class FloatModel; + +namespace gui +{ + +class Knob; + +class GranularPitchShifterControlDialog : public EffectControlDialog +{ + Q_OBJECT +public: + GranularPitchShifterControlDialog(GranularPitchShifterControls* controls); + ~GranularPitchShifterControlDialog() override = default; +public slots: + void showHelpWindow(); +}; + +class GranularPitchShifterHelpView : public QTextEdit +{ + Q_OBJECT +public: + static GranularPitchShifterHelpView* getInstance() + { + static GranularPitchShifterHelpView instance; + return &instance; + } + +private: + GranularPitchShifterHelpView(); + static QString s_helpText; +}; + +} // namespace gui + +} // namespace lmms + +#endif // LMMS_GRANULAR_PITCH_SHIFTER_CONTROLS_H diff --git a/plugins/GranularPitchShifter/GranularPitchShifterControls.cpp b/plugins/GranularPitchShifter/GranularPitchShifterControls.cpp new file mode 100755 index 00000000000..86e9a0cfd7b --- /dev/null +++ b/plugins/GranularPitchShifter/GranularPitchShifterControls.cpp @@ -0,0 +1,149 @@ +/* + * GranularPitchShifterControls.cpp + * + * Copyright (c) 2024 Lost Robot + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include + +#include "GranularPitchShifterControls.h" +#include "GranularPitchShifterEffect.h" + +namespace lmms +{ + +GranularPitchShifterControls::GranularPitchShifterControls(GranularPitchShifterEffect* effect) : + EffectControls(effect), + m_effect(effect), + m_pitchModel(1.f, -48.f, 24.f, 0.01f, this, tr("Pitch")), + m_sizeModel(10.f, 2.f, 1000.f, 0.001f, this, tr("Grain Size")), + m_sprayModel(0.005f, 0.f, 0.5f, 0.0001f, this, tr("Spray")), + m_jitterModel(0.f, 0.f, 1.f, 0.0001f, this, tr("Jitter")), + m_twitchModel(0.f, 0.f, 1.f, 0.0001f, this, tr("Twitch")), + m_pitchSpreadModel(0.f, -24.f, 24.f, 0.01f, this, tr("Pitch Stereo Spread")), + m_spraySpreadModel(0.f, 0.f, 1.f, 0.0001f, this, tr("Spray Stereo")), + m_shapeModel(2.f, 1.f, 2.f, 0.0001f, this, tr("Shape")), + m_fadeLengthModel(1.f, 0.001f, 1.f, 0.00001f, this, tr("Fade Length")), + m_feedbackModel(0.f, 0.f, 1.f, 0.00001f, this, tr("Feedback")), + m_minLatencyModel(0.01f, 0.f, 1.f, 0.00001f, this, tr("Minimum Allowed Latency")), + m_prefilterModel(true, this, tr("Prefilter")), + m_densityModel(1.f, 1.f, 16.f, 0.0001f, this, tr("Density")), + m_glideModel(0.01f, 0.f, 1.f, 0.0001f, this, tr("Glide")), + m_rangeModel(this, tr("Ring Buffer Length")) +{ + m_sizeModel.setScaleLogarithmic(true); + m_sprayModel.setScaleLogarithmic(true); + m_spraySpreadModel.setScaleLogarithmic(true); + m_minLatencyModel.setScaleLogarithmic(true); + m_densityModel.setScaleLogarithmic(true); + m_glideModel.setScaleLogarithmic(true); + + m_rangeModel.addItem(tr("5 Seconds")); + m_rangeModel.addItem(tr("10 Seconds (Size)")); + m_rangeModel.addItem(tr("40 Seconds (Size and Pitch)")); + m_rangeModel.addItem(tr("40 Seconds (Size and Spray and Jitter)")); + m_rangeModel.addItem(tr("120 Seconds (All of the above)")); + + connect(&m_rangeModel, &ComboBoxModel::dataChanged, this, &GranularPitchShifterControls::updateRange); +} + +void GranularPitchShifterControls::updateRange() +{ + switch (m_rangeModel.value()) + { + case 0:// 5 seconds + m_sizeModel.setRange(4.f, 1000.f, 0.001f); + m_pitchModel.setRange(-48.f, 24.f, 0.01f); + m_sprayModel.setRange(0.f, 0.5f, 0.0001f); + m_jitterModel.setRange(0.f, 1.f, 0.0001f); + break; + case 1:// 10 seconds (size) + m_sizeModel.setRange(2.f, 1000.f, 0.001f); + m_pitchModel.setRange(-48.f, 24.f, 0.01f); + m_sprayModel.setRange(0.f, 0.5f, 0.0001f); + m_jitterModel.setRange(0.f, 1.f, 0.0001f); + break; + case 2:// 40 seconds (size and pitch) + m_sizeModel.setRange(2.f, 1000.f, 0.001f); + m_pitchModel.setRange(-48.f, 48.f, 0.01f); + m_sprayModel.setRange(0.f, 0.5f, 0.0001f); + m_jitterModel.setRange(0.f, 1.f, 0.0001f); + break; + case 3:// 40 seconds (size and spray and jitter) + m_sizeModel.setRange(2.f, 1000.f, 0.001f); + m_pitchModel.setRange(-48.f, 24.f, 0.01f); + m_sprayModel.setRange(0.f, 20.f, 0.0001f); + m_jitterModel.setRange(0.f, 2.f, 0.0001f); + break; + case 4:// 120 seconds (all of the above) + m_sizeModel.setRange(2.f, 1000.f, 0.001f); + m_pitchModel.setRange(-48.f, 48.f, 0.01f); + m_sprayModel.setRange(0.f, 40.f, 0.0001f); + m_jitterModel.setRange(0.f, 2.f, 0.0001f); + break; + default: + break; + } + m_effect->sampleRateNeedsUpdate(); +} + +void GranularPitchShifterControls::loadSettings(const QDomElement& parent) +{ + // must be loaded first so the ranges are set properly + m_rangeModel.loadSettings(parent, "range"); + + m_pitchModel.loadSettings(parent, "pitch"); + m_sizeModel.loadSettings(parent, "size"); + m_sprayModel.loadSettings(parent, "spray"); + m_jitterModel.loadSettings(parent, "jitter"); + m_twitchModel.loadSettings(parent, "twitch"); + m_pitchSpreadModel.loadSettings(parent, "pitchSpread"); + m_spraySpreadModel.loadSettings(parent, "spraySpread"); + m_shapeModel.loadSettings(parent, "shape"); + m_fadeLengthModel.loadSettings(parent, "fadeLength"); + m_feedbackModel.loadSettings(parent, "feedback"); + m_minLatencyModel.loadSettings(parent, "minLatency"); + m_prefilterModel.loadSettings(parent, "prefilter"); + m_densityModel.loadSettings(parent, "density"); + m_glideModel.loadSettings(parent, "glide"); +} + +void GranularPitchShifterControls::saveSettings(QDomDocument& doc, QDomElement& parent) +{ + m_rangeModel.saveSettings(doc, parent, "range"); + m_pitchModel.saveSettings(doc, parent, "pitch"); + m_sizeModel.saveSettings(doc, parent, "size"); + m_sprayModel.saveSettings(doc, parent, "spray"); + m_jitterModel.saveSettings(doc, parent, "jitter"); + m_twitchModel.saveSettings(doc, parent, "twitch"); + m_pitchSpreadModel.saveSettings(doc, parent, "pitchSpread"); + m_spraySpreadModel.saveSettings(doc, parent, "spraySpread"); + m_shapeModel.saveSettings(doc, parent, "shape"); + m_fadeLengthModel.saveSettings(doc, parent, "fadeLength"); + m_feedbackModel.saveSettings(doc, parent, "feedback"); + m_minLatencyModel.saveSettings(doc, parent, "minLatency"); + m_prefilterModel.saveSettings(doc, parent, "prefilter"); + m_densityModel.saveSettings(doc, parent, "density"); + m_glideModel.saveSettings(doc, parent, "glide"); +} + + +} // namespace lmms diff --git a/plugins/GranularPitchShifter/GranularPitchShifterControls.h b/plugins/GranularPitchShifter/GranularPitchShifterControls.h new file mode 100755 index 00000000000..3ae8f881941 --- /dev/null +++ b/plugins/GranularPitchShifter/GranularPitchShifterControls.h @@ -0,0 +1,87 @@ +/* + * GranularPitchShifterControls.h + * + * Copyright (c) 2024 Lost Robot + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_GRANULAR_PITCH_SHIFTER_CONTROL_DIALOG_H +#define LMMS_GRANULAR_PITCH_SHIFTER_CONTROL_DIALOG_H + +#include "EffectControls.h" +#include "GranularPitchShifterControlDialog.h" + +namespace lmms +{ + +class GranularPitchShifterEffect; + +namespace gui +{ +class GranularPitchShifterControlDialog; +} + +class GranularPitchShifterControls : public EffectControls +{ + Q_OBJECT +public: + GranularPitchShifterControls(GranularPitchShifterEffect* effect); + ~GranularPitchShifterControls() override = default; + + void saveSettings(QDomDocument& doc, QDomElement& parent) override; + void loadSettings(const QDomElement& parent) override; + inline QString nodeName() const override + { + return "GranularPitchShifterControls"; + } + gui::EffectControlDialog* createView() override + { + return new gui::GranularPitchShifterControlDialog(this); + } + int controlCount() override { return 4; } + +public slots: + void updateRange(); + +private: + GranularPitchShifterEffect* m_effect; + FloatModel m_pitchModel; + FloatModel m_sizeModel; + FloatModel m_sprayModel; + FloatModel m_jitterModel; + FloatModel m_twitchModel; + FloatModel m_pitchSpreadModel; + FloatModel m_spraySpreadModel; + FloatModel m_shapeModel; + FloatModel m_fadeLengthModel; + FloatModel m_feedbackModel; + FloatModel m_minLatencyModel; + BoolModel m_prefilterModel; + FloatModel m_densityModel; + FloatModel m_glideModel; + ComboBoxModel m_rangeModel; + + friend class gui::GranularPitchShifterControlDialog; + friend class GranularPitchShifterEffect; +}; + +} // namespace lmms + +#endif // LMMS_GRANULAR_PITCH_SHIFTER_CONTROL_DIALOG_H diff --git a/plugins/GranularPitchShifter/GranularPitchShifterEffect.cpp b/plugins/GranularPitchShifter/GranularPitchShifterEffect.cpp new file mode 100755 index 00000000000..ee796d8e3a9 --- /dev/null +++ b/plugins/GranularPitchShifter/GranularPitchShifterEffect.cpp @@ -0,0 +1,289 @@ +/* + * GranularPitchShifter.cpp + * + * Copyright (c) 2024 Lost Robot + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#include "GranularPitchShifterEffect.h" + +#include +#include "embed.h" +#include "plugin_export.h" + + +namespace lmms +{ + +extern "C" +{ +Plugin::Descriptor PLUGIN_EXPORT granularpitchshifter_plugin_descriptor = +{ + LMMS_STRINGIFY(PLUGIN_NAME), + "Granular Pitch Shifter", + QT_TRANSLATE_NOOP("PluginBrowser", "Granular pitch shifter"), + "Lost Robot ", + 0x0100, + Plugin::Type::Effect, + new PluginPixmapLoader("logo"), + nullptr, + nullptr, +} ; +} + + +GranularPitchShifterEffect::GranularPitchShifterEffect(Model* parent, const Descriptor::SubPluginFeatures::Key* key) : + Effect(&granularpitchshifter_plugin_descriptor, parent, key), + m_granularpitchshifterControls(this), + m_prefilter({PrefilterLowpass(), PrefilterLowpass()}) +{ + autoQuitModel()->setValue(autoQuitModel()->maxValue()); + + changeSampleRate(); +} + + +bool GranularPitchShifterEffect::processAudioBuffer(sampleFrame* buf, const fpp_t frames) +{ + if (!isEnabled() || !isRunning()) { return false; } + + const float d = dryLevel(); + const float w = wetLevel(); + + const ValueBuffer* pitchBuf = m_granularpitchshifterControls.m_pitchModel.valueBuffer(); + const ValueBuffer* pitchSpreadBuf = m_granularpitchshifterControls.m_pitchSpreadModel.valueBuffer(); + + const float size = m_granularpitchshifterControls.m_sizeModel.value(); + const float shape = m_granularpitchshifterControls.m_shapeModel.value(); + const float jitter = m_granularpitchshifterControls.m_jitterModel.value(); + const float twitch = m_granularpitchshifterControls.m_twitchModel.value(); + const float spray = m_granularpitchshifterControls.m_sprayModel.value(); + const float spraySpread = m_granularpitchshifterControls.m_spraySpreadModel.value(); + const float density = m_granularpitchshifterControls.m_densityModel.value(); + const float glide = m_granularpitchshifterControls.m_glideModel.value(); + const int minLatency = m_granularpitchshifterControls.m_minLatencyModel.value() * m_sampleRate; + const float densityInvRoot = std::sqrt(1.f / density); + const float feedback = m_granularpitchshifterControls.m_feedbackModel.value(); + const float fadeLength = 1.f / m_granularpitchshifterControls.m_fadeLengthModel.value(); + const bool prefilter = m_granularpitchshifterControls.m_prefilterModel.value(); + + if (glide != m_oldGlide) + { + m_oldGlide = glide; + m_glideCoef = std::exp(-1 / (glide * m_sampleRate)); + } + + const float shapeK = cosWindowApproxK(shape); + const int sizeSamples = m_sampleRate / size; + const float waitMult = sizeSamples / (density * 2); + + for (fpp_t f = 0; f < frames; ++f) + { + const double pitch = pitchBuf ? pitchBuf->value(f) : m_granularpitchshifterControls.m_pitchModel.value(); + const double pitchSpread = (pitchSpreadBuf ? pitchSpreadBuf->value(f) : m_granularpitchshifterControls.m_pitchSpreadModel.value()) * 0.5f; + + // interpolate pitch depending on glide + for (int i = 0; i < 2; ++i) + { + double targetVal = pitch + pitchSpread * (i ? 1. : -1.); + + if (targetVal == m_truePitch[i]) { continue; } + m_updatePitches = true; + + m_truePitch[i] = m_glideCoef * m_truePitch[i] + (1. - m_glideCoef) * targetVal; + // we crudely lock the pitch to the target value once it gets close enough, so we can save on CPU + if (std::abs(targetVal - m_truePitch[i]) < GlideSnagRadius) { m_truePitch[i] = targetVal; } + } + + // this stuff is computationally expensive, so we should only do it when necessary + if (m_updatePitches) + { + m_updatePitches = false; + + std::array speed = { + std::exp2(m_truePitch[0] * (1. / 12.)), + std::exp2(m_truePitch[1] * (1. / 12.)) + }; + std::array ratio = { + speed[0] / m_speed[0], + speed[1] / m_speed[1] + }; + + for (int i = 0; i < m_grainCount; ++i) + { + for (int j = 0; j < 2; ++j) + { + m_grains[i].grainSpeed[j] *= ratio[j]; + + // we unfortunately need to do extra stuff to ensure these don't shoot past the write index... + if (m_grains[i].grainSpeed[j] > 1) + { + double distance = m_writePoint - m_grains[i].readPoint[j] - SafetyLatency; + if (distance <= 0) { distance += m_ringBufLength; } + double grainSpeedRequired = ((m_grains[i].grainSpeed[j] - 1.) / distance) * (1. - m_grains[i].phase); + m_grains[i].phaseSpeed[j] = std::max(m_grains[i].phaseSpeed[j], grainSpeedRequired); + } + } + } + m_speed[0] = speed[0]; + m_speed[1] = speed[1]; + + // prevent aliasing by lowpassing frequencies that the pitch shifting would push above nyquist + m_prefilter[0].setCoefs(m_sampleRate, std::min(m_nyquist / static_cast(speed[0]), m_nyquist) * PrefilterBandwidth); + m_prefilter[1].setCoefs(m_sampleRate, std::min(m_nyquist / static_cast(speed[1]), m_nyquist) * PrefilterBandwidth); + } + + std::array s = {0, 0}; + std::array filtered = {buf[f][0], buf[f][1]}; + + // spawn a new grain if it's time + if (++m_timeSinceLastGrain >= m_nextWaitRandomization * waitMult) + { + m_timeSinceLastGrain = 0; + double randThing = (fast_rand()/static_cast(FAST_RAND_MAX) * 2. - 1.); + m_nextWaitRandomization = std::exp2(randThing * twitch); + double grainSpeed = 1. / std::exp2(randThing * jitter); + + std::array sprayResult = {0, 0}; + if (spray > 0) + { + sprayResult[0] = (fast_rand() / static_cast(FAST_RAND_MAX)) * spray * m_sampleRate; + sprayResult[1] = linearInterpolate( + sprayResult[0], + (fast_rand() / static_cast(FAST_RAND_MAX)) * spray * m_sampleRate, + spraySpread); + } + + std::array readPoint; + int latency = std::max(static_cast(std::max(sizeSamples * (std::max(m_speed[0], m_speed[1]) * grainSpeed - 1.), 0.) + SafetyLatency), minLatency); + for (int i = 0; i < 2; ++i) + { + readPoint[i] = m_writePoint - latency - sprayResult[i]; + if (readPoint[i] < 0) { readPoint[i] += m_ringBufLength; } + } + const double phaseInc = 1. / sizeSamples; + m_grains.push_back(Grain(grainSpeed * m_speed[0], grainSpeed * m_speed[1], phaseInc, phaseInc, readPoint[0], readPoint[1])); + ++m_grainCount; + } + + for (int i = 0; i < m_grainCount; ++i) + { + m_grains[i].phase += std::max(m_grains[i].phaseSpeed[0], m_grains[i].phaseSpeed[1]); + if (m_grains[i].phase >= 1) + { + // grain is done, delete it + std::swap(m_grains[i], m_grains[m_grainCount-1]); + m_grains.pop_back(); + --i; + --m_grainCount; + continue; + } + + m_grains[i].readPoint[0] += m_grains[i].grainSpeed[0]; + m_grains[i].readPoint[1] += m_grains[i].grainSpeed[1]; + if (m_grains[i].readPoint[0] >= m_ringBufLength) { m_grains[i].readPoint[0] -= m_ringBufLength; } + if (m_grains[i].readPoint[1] >= m_ringBufLength) { m_grains[i].readPoint[1] -= m_ringBufLength; } + + const float fadePos = std::clamp((-std::abs(-2.f * static_cast(m_grains[i].phase) + 1.f) + 0.5f) * fadeLength + 0.5f, 0.f, 1.f); + const float windowVal = cosHalfWindowApprox(fadePos, shapeK); + s[0] += getHermiteSample(m_grains[i].readPoint[0], 0) * windowVal; + s[1] += getHermiteSample(m_grains[i].readPoint[1], 1) * windowVal; + } + + // note that adding two signals together, when uncorrelated, results in a signal power multiplication of sqrt(2), not 2 + s[0] *= densityInvRoot; + s[1] *= densityInvRoot; + + // 1-pole highpass for DC offset removal, to make feedback safer + s[0] -= (m_dcVal[0] = (1.f - m_dcCoeff) * s[0] + m_dcCoeff * m_dcVal[0]); + s[1] -= (m_dcVal[1] = (1.f - m_dcCoeff) * s[1] + m_dcCoeff * m_dcVal[1]); + + // cheap safety saturator to protect against infinite feedback + if (feedback > 0) + { + s[0] = safetySaturate(s[0]); + s[1] = safetySaturate(s[1]); + } + + if (++m_writePoint >= m_ringBufLength) + { + m_writePoint = 0; + } + if (prefilter) + { + filtered[0] = m_prefilter[0].process(filtered[0]); + filtered[1] = m_prefilter[1].process(filtered[1]); + } + + m_ringBuf[m_writePoint][0] = filtered[0] + s[0] * feedback; + m_ringBuf[m_writePoint][1] = filtered[1] + s[1] * feedback; + + buf[f][0] = d * buf[f][0] + w * s[0]; + buf[f][1] = d * buf[f][1] + w * s[1]; + } + + if (m_sampleRateNeedsUpdate) + { + m_sampleRateNeedsUpdate = false; + changeSampleRate(); + } + + return isRunning(); +} + +void GranularPitchShifterEffect::changeSampleRate() +{ + const int range = m_granularpitchshifterControls.m_rangeModel.value(); + const float ringBufLength = RangeSeconds[range]; + + m_sampleRate = Engine::audioEngine()->outputSampleRate(); + m_nyquist = m_sampleRate / 2; + + m_ringBufLength = m_sampleRate * ringBufLength; + m_ringBuf.resize(m_ringBufLength); + for (size_t i = 0; i < m_ringBufLength; ++i) + { + m_ringBuf[i][0] = 0; + m_ringBuf[i][1] = 0; + } + m_writePoint = 0; + + m_oldGlide = -1; + + m_updatePitches = true; + + m_grains.clear(); + m_grainCount = 0; + m_grains.reserve(8);// arbitrary + + m_dcCoeff = std::exp(-2.0 * F_PI * DcRemovalHz / m_sampleRate); +} + + +extern "C" +{ +// necessary for getting instance out of shared lib +PLUGIN_EXPORT Plugin* lmms_plugin_main(Model* parent, void* data) +{ + return new GranularPitchShifterEffect(parent, static_cast(data)); +} +} + +} // namespace lmms diff --git a/plugins/GranularPitchShifter/GranularPitchShifterEffect.h b/plugins/GranularPitchShifter/GranularPitchShifterEffect.h new file mode 100755 index 00000000000..111d0538d0c --- /dev/null +++ b/plugins/GranularPitchShifter/GranularPitchShifterEffect.h @@ -0,0 +1,185 @@ +/* + * GranularPitchShifter.h + * + * Copyright (c) 2024 Lost Robot + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_GRANULAR_PITCH_SHIFTER_EFFECT_H +#define LMMS_GRANULAR_PITCH_SHIFTER_EFFECT_H + +#include "Effect.h" +#include "GranularPitchShifterControls.h" + +#include "BasicFilters.h" +#include "interpolation.h" + +namespace lmms +{ + +constexpr float PrefilterBandwidth = 0.96f;// 96% of nyquist +constexpr double GlideSnagRadius = 0.001; +constexpr int SafetyLatency = 3; +constexpr float RangeSeconds[5] = {5, 10, 40, 40, 120}; +constexpr float DcRemovalHz = 7.f; +constexpr float SatuSafeVol = 16.f; +constexpr float SatuStrength = 0.001f; + + +class GranularPitchShifterEffect : public Effect +{ +public: + GranularPitchShifterEffect(Model* parent, const Descriptor::SubPluginFeatures::Key* key); + ~GranularPitchShifterEffect() override = default; + bool processAudioBuffer(sampleFrame* buf, const fpp_t frames) override; + + EffectControls* controls() override + { + return &m_granularpitchshifterControls; + } + + // double index and fraction are required for good quality + float getHermiteSample(double index, int ch) + { + const int index_floor = static_cast(index); + const double fraction = index - index_floor; + + float v0, v1, v2, v3; + + if (index_floor == 0) { v0 = m_ringBuf[m_ringBuf.size() - 1][ch]; } + else { v0 = m_ringBuf[index_floor - 1][ch]; } + + v1 = m_ringBuf[index_floor][ch]; + + if(index_floor >= m_ringBuf.size() - 2) + { + v2 = m_ringBuf[(index_floor + 1) % m_ringBuf.size()][ch]; + v3 = m_ringBuf[(index_floor + 2) % m_ringBuf.size()][ch]; + } + else + { + v2 = m_ringBuf[index_floor + 1][ch]; + v3 = m_ringBuf[index_floor + 2][ch]; + } + + return hermiteInterpolate(v0, v1, v2, v3, static_cast(fraction)); + } + + // adapted from signalsmith's crossfade approximation: + // https://signalsmith-audio.co.uk/writing/2021/cheap-energy-crossfade + float cosHalfWindowApprox(float x, float k) + { + float A = x * (1 - x); + float B = A * (1 + k * A); + float C = (B + x); + return C * C; + } + // 1-2 fades between equal-gain and equal-power + float cosWindowApproxK(float p) + { + return -6.0026608f + p * (6.8773512f - 1.5838104f * p); + } + + // designed to use minimal CPU if the input isn't loud + float safetySaturate(float input) + { + float absInput = std::abs(input); + return absInput <= SatuSafeVol ? input : + std::copysign((absInput - SatuSafeVol) / (1 + (absInput - SatuSafeVol) * SatuStrength) + SatuSafeVol, input); + } + + void sampleRateNeedsUpdate() { m_sampleRateNeedsUpdate = true; } + + void changeSampleRate(); + +private: + struct PrefilterLowpass + { + float m_v0z = 0.f, m_v1 = 0.f, m_v2 = 0.f; + float m_g1, m_g2, m_g3, m_g4; + + void setCoefs(float sampleRate, float cutoff) + { + const float g = std::tan(F_PI * cutoff / sampleRate); + const float ginv = g / (1.f + g * (g + F_SQRT_2)); + m_g1 = ginv; + m_g2 = 2.f * (g + F_SQRT_2) * ginv; + m_g3 = g * ginv; + m_g4 = 2.f * ginv; + } + + float process(float input) + { + const float v1z = m_v1; + const float v3 = input + m_v0z - 2.f * m_v2; + m_v1 += m_g1 * v3 - m_g2 * v1z; + m_v2 += m_g3 * v3 + m_g4 * v1z; + m_v0z = input; + return m_v2; + } + }; + + struct Grain + { + Grain(double grainSpeedL, double grainSpeedR, double phaseSpeedL, double phaseSpeedR, double readPointL, double readPointR) : + readPoint{readPointL, readPointR}, + phaseSpeed{phaseSpeedL, phaseSpeedR}, + grainSpeed{grainSpeedL, grainSpeedR}, + phase{0} + {} + std::array readPoint; + std::array phaseSpeed; + std::array grainSpeed; + double phase; + }; + + GranularPitchShifterControls m_granularpitchshifterControls; + + std::vector> m_ringBuf; + std::vector m_grains; + + std::array m_prefilter; + std::array m_speed = {1, 1}; + std::array m_truePitch = {0, 0}; + std::array m_dcVal = {0, 0}; + + float m_sampleRate; + float m_nyquist; + float m_nextWaitRandomization = 1; + float m_dcCoeff; + + int m_ringBufLength = 0; + int m_writePoint = 0; + int m_grainCount = 0; + int m_timeSinceLastGrain = 0; + + double m_oldGlide = -1; + double m_glideCoef = 0; + + bool m_sampleRateNeedsUpdate = false; + bool m_updatePitches = true; + + friend class GranularPitchShifterControls; +}; + + +} // namespace lmms + +#endif // LMMS_GRANULAR_PITCH_SHIFTER_EFFECT_H diff --git a/plugins/GranularPitchShifter/artwork.png b/plugins/GranularPitchShifter/artwork.png new file mode 100755 index 00000000000..632750bcf5e Binary files /dev/null and b/plugins/GranularPitchShifter/artwork.png differ diff --git a/plugins/GranularPitchShifter/help_active.png b/plugins/GranularPitchShifter/help_active.png new file mode 100755 index 00000000000..d68707acb46 Binary files /dev/null and b/plugins/GranularPitchShifter/help_active.png differ diff --git a/plugins/GranularPitchShifter/help_inactive.png b/plugins/GranularPitchShifter/help_inactive.png new file mode 100644 index 00000000000..0113876bc59 Binary files /dev/null and b/plugins/GranularPitchShifter/help_inactive.png differ diff --git a/plugins/GranularPitchShifter/logo.png b/plugins/GranularPitchShifter/logo.png new file mode 100755 index 00000000000..9340da708dd Binary files /dev/null and b/plugins/GranularPitchShifter/logo.png differ diff --git a/plugins/GranularPitchShifter/prefilter_active.png b/plugins/GranularPitchShifter/prefilter_active.png new file mode 100755 index 00000000000..95d3ccd7099 Binary files /dev/null and b/plugins/GranularPitchShifter/prefilter_active.png differ diff --git a/plugins/GranularPitchShifter/prefilter_inactive.png b/plugins/GranularPitchShifter/prefilter_inactive.png new file mode 100755 index 00000000000..8efb1ff1d4a Binary files /dev/null and b/plugins/GranularPitchShifter/prefilter_inactive.png differ