Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Synchro, a simple phase modulation synth #5147

Draft
wants to merge 54 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
1b7ded0
Created Synchro, a simple phase modulation synth
rubiefawn Aug 24, 2019
ee9bed4
Implemented a read-only mode for the Graph widget
rubiefawn Aug 24, 2019
16e74b9
fixed an error in the waveform graphs with inputs > 2*PI
rubiefawn Aug 24, 2019
26ad46d
Convention fixes + graph m_isReadOnly is now private
rubiefawn Aug 24, 2019
dd32854
Removed redundant read-only feature, (use inherited QWidget enabled)
rubiefawn Aug 24, 2019
63e126f
Removed a parameter I forgot to remove in the last commit
rubiefawn Aug 24, 2019
215521f
Revert formatting in Graph.h
rubiefawn Aug 27, 2019
0a5302c
Revert formatting in Graph.cpp
rubiefawn Aug 27, 2019
c4304cc
Minor code convention fixes
rubiefawn Aug 27, 2019
3c1726a
Minor coding convention fixes
rubiefawn Aug 27, 2019
c005793
Fix the deleted macro that was breaking the plugin
rubiefawn Aug 27, 2019
30947d2
Performance optimizations round 1 (.cpp only)
rubiefawn Aug 29, 2019
fe45708
Performance optimizations round 1 (.h)
rubiefawn Aug 29, 2019
92ad0a0
Various fixes to performance optimizations round 1
rubiefawn Aug 29, 2019
fe57e52
Typo fix
rubiefawn Aug 29, 2019
afd98db
Typo fix
rubiefawn Aug 29, 2019
f01aff9
Typo fix
rubiefawn Aug 29, 2019
7cbc15c
:art: Enforce max 80 character line length
rubiefawn Sep 12, 2019
80e6aca
:lightning: tanh over atan
rubiefawn Sep 18, 2019
33fb791
:art: `Graph` over `View`
rubiefawn Sep 18, 2019
1271930
:bug: Fix result graph error when modulation is applied
rubiefawn Sep 18, 2019
30c3ebe
Implemented a read-only mode for the Graph widget
rubiefawn Aug 24, 2019
be1717c
Convention fixes + graph m_isReadOnly is now private
rubiefawn Aug 24, 2019
e8587b2
Removed redundant read-only feature, (use inherited QWidget enabled)
rubiefawn Aug 24, 2019
de628f1
Removed a parameter I forgot to remove in the last commit
rubiefawn Aug 24, 2019
5b478ee
Revert formatting in Graph.h
rubiefawn Aug 27, 2019
67d3f75
Revert formatting in Graph.cpp
rubiefawn Aug 27, 2019
b8bb3cc
:lightning: tanh over atan
rubiefawn Sep 18, 2019
5a8a7c1
:bug: Fix result graph error when modulation is applied
rubiefawn Sep 18, 2019
9557d15
Fix merge conflict
PhysSong Apr 22, 2020
e94a824
:art: Add full-size UI with dummy envelope knobs
rubiefawn Apr 22, 2020
642a4bf
🐛 Bugfixes & review compliance
rubiefawn Apr 22, 2020
8068eb2
:bug: More review compliance, naming improvements
rubiefawn Apr 22, 2020
db9f4b7
:sparkles: Add sample-exactness to modulation controls
rubiefawn Apr 22, 2020
e965d8d
✨ Add envelopes
rubiefawn Apr 23, 2020
487562a
:art: Rewrite for cleanliness and performance
rubiefawn Apr 23, 2020
9d317c6
:bug: Fix compiler warnings
rubiefawn Apr 24, 2020
09dc2e6
:bug: Enable instrument track processing, fix envelopes
rubiefawn Apr 25, 2020
8b13101
:zap: Fast hyperbolic tangent function
rubiefawn Apr 25, 2020
5e4710c
:bug: Fix breaking typo
rubiefawn Apr 25, 2020
df30195
:bug: Remove breaking )
rubiefawn Apr 25, 2020
5076f3a
:art: Comply with code reviews
rubiefawn Aug 30, 2020
1780d2d
:bug: Fix improper overrides
rubiefawn Aug 30, 2020
7e295c0
:art: Comply with code review
rubiefawn Aug 30, 2020
4150360
:bug: Add missing open parentheses
rubiefawn Aug 30, 2020
25f6c4e
:bug: Fix misunderstood code
rubiefawn Sep 7, 2020
02b8055
:bug: Remove typo and reorder function declaration
rubiefawn Sep 7, 2020
e4282e4
:bug: Remove pointless passing of sample rate into function
rubiefawn Sep 7, 2020
8ffd2a5
Rename things to match after reorg
rubiefawn Jan 24, 2023
6846bce
:construction: attempt to fix oversampling
rubiefawn Jul 9, 2023
e94b3c1
Merge remote-tracking branch 'origin/master' into master
michaelgregorius Oct 7, 2024
d67a3ed
Fix SynchroSynth compilation
michaelgregorius Oct 7, 2024
7492469
spaghetti code time
rubiefawn Oct 16, 2024
3ec63f1
appease CI checks
rubiefawn Oct 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmake/modules/PluginList.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ SET(LMMS_PLUGIN_LIST
StereoEnhancer
StereoMatrix
Stk
Synchro
TapTempo
VstBase
Vestige
Expand Down
6 changes: 6 additions & 0 deletions plugins/Synchro/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
INCLUDE(BuildPlugin)

BUILD_PLUGIN(synchro SynchroSynth.cpp SynchroSynth.h MOCFILES SynchroSynth.h
EMBEDDED_RESOURCES "${CMAKE_CURRENT_SOURCE_DIR}/*.png")

TARGET_LINK_LIBRARIES(synchro hiir)
347 changes: 347 additions & 0 deletions plugins/Synchro/SynchroSynth.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
#include "SynchroSynth.h"

#include <QDomElement>
#include <cmath>

#include "AudioEngine.h"
#include "Engine.h"
#include "Plugin.h"
#include "InstrumentTrack.h"
#include "lmms_basics.h"
#include "plugin_export.h"

namespace lmms
{

extern "C"
{
Plugin::Descriptor PLUGIN_EXPORT synchro_plugin_descriptor =
{
LMMS_STRINGIFY(PLUGIN_NAME),
"Synchro",
QT_TRANSLATE_NOOP("PluginBrowser", "2-oscillator PM synth"),
"Fawn <rubiefawn/at/gmail/dot/com>",
0x0100, // plugin version, why hexadecimal?
Plugin::Type::Instrument,
new PluginPixmapLoader("logo"),
nullptr, nullptr
};

PLUGIN_EXPORT Plugin *lmms_plugin_main(Model *m, void *) { return new Synchro(static_cast<InstrumentTrack*>(m)); }
}

// static inline float reducePhase(float phase) { return fmod(lmms::F_2PI + fmod(phase, lmms::F_2PI), lmms::F_2PI); }
static inline float reducePhase(float phase) { return fmod(1.f + fmod(phase, 1.f), 1.f); }

// Expects phase in the range of 0..1, preferably offset by 0.25
// If the phase is is radians just multiply it by 1 / τ
static inline float tri(float phase) { return 4 * abs(phase - floor(phase + .5f)) - 1; }

// This is a naive but readable version of the waveform function.
// phase is expected to be in the range 0..1.
[[maybe_unused]]
static float ezsauce(float phase, float drive, float sync, float pulse)
{
const float trianglewave = tri(phase * sync + .25f); // offset so waveform starts at 0
const float saturated = tanh(drive * trianglewave) / tanh(drive);
const float attenuation = pow(1 - phase, pulse); // attenuation towards the end of the waveform
return saturated * attenuation;
}

//TODO actually document the parameters
// This is a simplified but tremendously less-readable version of the function.
// Faster approximation functions in place of std::exp() and std::pow() may yield better performance.
// `phase` is expected to be in the range 0..1
static float sauce(float phase, float drive, float sync, float pulse)
{
const float a = exp(tri(phase * sync + .25f) * drive * 2);
const float b = exp(drive * 2);
const float c = pow(1 - phase, pulse);
return ((a - 1) * (b + 1)) / ((a + 1) * (b - 1)) * c;
}

//TODO Document the arbitrary Magic Numbers
static float sauce(float phase, float drive, float sync, float pulse, float harmonics)
{
const float synced = phase * sync + .25f;
const float t = tri(synced);
const float h = tri(synced * 32) * .5f + tri(synced * 38) * .03f;
const float a = exp((t + h * harmonics) * drive * 2);
const float b = exp(drive * 2);
const float c = pow(1 - phase, pulse);
return ((a - 1) * (b + 1)) / ((a + 1) * (b - 1)) * c;
}

Synchro::Synchro(InstrumentTrack *track) :
Instrument(track, &synchro_plugin_descriptor),
m_carrier(this, "carrier"),
m_modulator(this, "modulator"),
m_modAmt(0.f, 0.f, 1.f, .00001f, this, tr("modulation amount")),
m_modScale(1.f, -2.f, 2.f, .25f, this, tr("modulation scale")),
m_harmonics(0.f, 0.f, 1.f, 0.00001f, this, tr("harmonics")),
m_octaveRatio(-1, -4, 0, 1, this, tr("octave ratio")),
m_oversampling(2),
m_carrierWaveform(-1.f, 1.f, SYNCHRO_GRAPH_RESOLUTION, this),
m_modulatorWaveform(-1.f, 1.f, SYNCHRO_GRAPH_RESOLUTION, this),
m_resultingWaveform(-1.f, 1.f, SYNCHRO_GRAPH_RESOLUTION, this)
{
m_modulator.drive.setInitValue(2.f);
connect(Engine::audioEngine(), SIGNAL(sampleRateChanged()), this, SLOT(effectiveSampleRateChanged()));
//TODO connect oversampling slot once it has UI controls
// connect(&m_oversampling, SIGNAL(dataChanged()), this, SLOT(effectiveSampleRateChanged()));
connect(&m_carrier.drive, SIGNAL(dataChanged()), this, SLOT(carrierChanged()));
connect(&m_carrier.sync, SIGNAL(dataChanged()), this, SLOT(carrierChanged()));
connect(&m_carrier.pulse, SIGNAL(dataChanged()), this, SLOT(carrierChanged()));
connect(&m_modulator.drive, SIGNAL(dataChanged()), this, SLOT(modulatorChanged()));
connect(&m_modulator.sync, SIGNAL(dataChanged()), this, SLOT(modulatorChanged()));
connect(&m_modulator.pulse, SIGNAL(dataChanged()), this, SLOT(modulatorChanged()));
connect(&m_modAmt, SIGNAL(dataChanged()), this, SLOT(eitherOscChanged()));
connect(&m_octaveRatio, SIGNAL(dataChanged()), this, SLOT(carrierChanged()));
connect(&m_harmonics, SIGNAL(dataChanged()), this, SLOT(modulatorChanged()));
connect(&m_modScale, SIGNAL(dataChanged()), this, SLOT(eitherOscChanged()));

carrierChanged();
modulatorChanged();
effectiveSampleRateChanged();
}

gui::PluginView *Synchro::instantiateView(QWidget *parent) { return new gui::SynchroView(this, parent); }

void Synchro::playNote(NotePlayHandle *nph, SampleFrame *buf)
{
if (!nph->m_pluginData) { nph->m_pluginData = new std::array<float, 2>; }

std::array<float, 2> *phases = static_cast<std::array<float, 2>*>(nph->m_pluginData);
const fpp_t len = nph->framesLeftForCurrentPeriod();
const f_cnt_t offset = nph->noteOffset();
const sample_rate_t internalSampleRate = Engine::audioEngine()->outputSampleRate() * m_oversampling;
const float phasePerSample = nph->frequency() / internalSampleRate;
//TODO Experiment with exp2 approximation function (2^x), stdlib compromises speed for accuracy
const float pitchDiff = exp2(m_octaveRatio.value());

//FIXME there's currently a bug where the modulator will reset its own phase every time the carrier completes a
// cycle. The shape of the modulator in each of those windows is correct. If the modulator is -1 octave, it should
// cycle once per two carrier cycles, but instead it just plays the first half of its cycle twice.
// help
for (f_cnt_t i = 0; i < len * m_oversampling; ++i)
{
auto frame = offset + (i / m_oversampling);
(*phases)[0] = reducePhase((*phases)[0] + phasePerSample);
(*phases)[1] = reducePhase((*phases)[1] + phasePerSample * pitchDiff);
const float modulation = sauce(
(*phases)[1],
m_modulator.driveExact(frame),
m_modulator.syncExact(frame),
m_modulator.pulseExact(frame),
harmonicsExact(frame)
);
//TODO The current modulation method is to apply the modulation as an
// additive offset to the phase when generating the waveform, and does not
// affect the "true" phase of the carrier. Try applying the modulation
// as a multiplicative offset to `carrierPhaseInc` and see if that sounds
// and/or behaves any better.
const float phase = reducePhase((*phases)[0] + modulation * modAmtExact(frame) * m_modScale.value());
m_buf[0][i] = sauce(phase, m_carrier.driveExact(frame), m_carrier.syncExact(frame), m_carrier.pulseExact(frame));
}

auto w = 0; // double buffer index, should only ever be 0 or 1
for (fpp_t len2 = len * m_oversampling >> 1; len2 >= len; len2 >>= 1)
{
for (f_cnt_t i = 0, j = 0; i < len2; ++i, j = i << 1)
{
//FIXME use hiir downsampling
m_buf[w^1][i] = (m_buf[w][j] + m_buf[w][1+j]) / 2.f;
}
w ^= 1;
}

// I would just memcpy twice but unfortunately buf is one array of two-channel samples rather than
// two arrays of single-channel samples. There's probably a clever way to work around this during downsampling but
// I'm not that clever
for (f_cnt_t f = 0; f < len; ++f) { buf[f+offset] = SampleFrame(m_buf[w][f]); }
instrumentTrack()->processAudioBuffer(buf, offset + len, nph);
}

void Synchro::deleteNotePluginData(NotePlayHandle *nph) { delete static_cast<std::array<float, 2>*>(nph->m_pluginData); };

QString Synchro::nodeName() const { return synchro_plugin_descriptor.displayName; }

void Synchro::saveSettings(QDomDocument &doc, QDomElement &parent)
{
parent.setAttribute("version", synchro_plugin_descriptor.version);
m_modScale.saveSettings(doc, parent, "modulation scale");
m_harmonics.saveSettings(doc, parent, "harmonics");
m_octaveRatio.saveSettings(doc, parent, "octave ratio");

m_carrier.drive.saveSettings(doc, parent, "carrier drive");
m_carrier.sync.saveSettings(doc, parent, "carrier sync");
m_carrier.pulse.saveSettings(doc, parent, "carrier pulse");

m_modulator.drive.saveSettings(doc, parent, "modulator drive");
m_modulator.sync.saveSettings(doc, parent, "modulator sync");
m_modulator.pulse.saveSettings(doc, parent, "modulator pulse");
}

void Synchro::loadSettings(const QDomElement &thisElement)
{
//TODO Check if preset was made with an older version, handle if necesary
m_modScale.loadSettings(thisElement, "modulation scale");
m_harmonics.loadSettings(thisElement, "harmonics");
m_octaveRatio.loadSettings(thisElement, "octave ratio");

m_carrier.drive.loadSettings(thisElement, "carrier drive");
m_carrier.sync.loadSettings(thisElement, "carrier sync");
m_carrier.pulse.loadSettings(thisElement, "carrier pulse");

m_modulator.drive.loadSettings(thisElement, "modulator drive");
m_modulator.sync.loadSettings(thisElement, "modulator sync");
m_modulator.pulse.loadSettings(thisElement, "modulator pulse");
}

void Synchro::effectiveSampleRateChanged()
{
//TODO Set up HIIR downsampling filter
m_buf[0].resize(Engine::audioEngine()->framesPerPeriod() * m_oversampling);
m_buf[1].resize(Engine::audioEngine()->framesPerPeriod() * m_oversampling);
}

void Synchro::carrierChanged()
{
const float pitchDiff = exp2(-m_octaveRatio.value());
for (auto i = 0; i < SYNCHRO_GRAPH_RESOLUTION; ++i)
{
const float phase = (float)i / SYNCHRO_GRAPH_RESOLUTION;
const float sample = sauce(
reducePhase(phase * pitchDiff),
m_carrier.drive.value(),
m_carrier.sync.value(),
m_carrier.pulse.value()
);
m_carrierWaveform.setSampleAt(i, sample);
}
eitherOscChanged();
}

void Synchro::modulatorChanged()
{
for (auto i = 0; i < SYNCHRO_GRAPH_RESOLUTION; ++i)
{
const float sample = sauce(
(float)i / SYNCHRO_GRAPH_RESOLUTION,
m_modulator.drive.value(),
m_modulator.sync.value(),
m_modulator.pulse.value(),
m_harmonics.value() * 0.15
);
m_modulatorWaveform.setSampleAt(i, sample);
}
eitherOscChanged();
}

//TODO add oversampling for the graphs lol they get so screwed up
void Synchro::eitherOscChanged()
{
const float pitchDiff = exp2(-m_octaveRatio.value());
for (auto i = 0; i < SYNCHRO_GRAPH_RESOLUTION; ++i)
{
const float phase = (float)i / SYNCHRO_GRAPH_RESOLUTION;
const float modAmt = sauce(phase, m_modulator.drive.value(), m_modulator.sync.value(), m_modulator.pulse.value(), m_harmonics.value() * 0.15f);
const float carrierPhase = reducePhase(phase * pitchDiff + modAmt * m_modAmt.value() * m_modScale.value());
m_resultingWaveform.setSampleAt(i, sauce(carrierPhase, m_carrier.drive.value(), m_carrier.sync.value(), m_carrier.pulse.value()));
}
}

gui::SynchroView::SynchroView(Instrument *instrument, QWidget *parent) :
InstrumentViewFixedSize(instrument, parent)
{
setAutoFillBackground(true);
QPalette pal;
//TODO use svg background once svg support is complete
pal.setBrush(backgroundRole(), PLUGIN_NAME::getIconPixmap("artwork"));
setPalette(pal);

constexpr int graph_w = SYNCHRO_GRAPH_RESOLUTION, graph_h = 77, graph_x = 18;
#define SYNCHRO_GRAPH_INIT(IT) do {\
IT->setAutoFillBackground(false);\
IT->setEnabled(false); } while (0)

m_carrierWaveform = new Graph(this, Graph::Style::LinearNonCyclic, graph_w, graph_h);
m_carrierWaveform->setGraphColor(SYNCHRO_CYAN);
m_carrierWaveform->move(graph_x, 165);
SYNCHRO_GRAPH_INIT(m_carrierWaveform);

m_modulatorWaveform = new Graph(this, Graph::Style::LinearNonCyclic, graph_w, graph_h);
m_modulatorWaveform->setGraphColor(SYNCHRO_RED);
m_modulatorWaveform->move(graph_x, 262);
SYNCHRO_GRAPH_INIT(m_modulatorWaveform);

m_resultingWaveform = new Graph(this, Graph::Style::LinearNonCyclic, graph_w, graph_h);
m_resultingWaveform->setGraphColor(SYNCHRO_YELLOW);
m_resultingWaveform->move(graph_x, 68);
SYNCHRO_GRAPH_INIT(m_resultingWaveform);

constexpr int knob_xy = -3; //HACK get rid of this shit
constexpr int knob_x[] = { 220, 285, 350, 416 };
constexpr int knob_y[] = { 86, 183, 280 };

//TODO custom styled knobs that use the colors of their corresponding UI section
m_modAmt = new Knob(KnobType::Dark28, this);
m_modAmt->move(knob_x[0] + knob_xy, knob_y[0] + knob_xy);
m_modAmt->setHintText(tr("modulation"), "×"); //TODO make the UI show 0-100%

m_modScale = new Knob(KnobType::Dark28, this);
m_modScale->move(knob_x[1] + knob_xy, knob_y[0] + knob_xy);
m_modScale->setHintText(tr("modulation scale"), "×"); //TODO make the UI show 0-100%

m_harmonics = new Knob(KnobType::Dark28, this);
m_harmonics->move(knob_x[3] + knob_xy, knob_y[2] + knob_xy);
m_harmonics->setHintText(tr("harmonics"), "×"); //TODO make the UI show 0-100%

m_octaveRatio = new Knob(KnobType::Dark28, this);
m_octaveRatio->move(knob_x[3] + knob_xy, knob_y[1] + knob_xy);
m_octaveRatio->setHintText(tr("octave ratio"), "octaves");

m_carrierDrive = new Knob(KnobType::Dark28, this);
m_carrierDrive->move(knob_x[0] + knob_xy, knob_y[1] + knob_xy);
m_carrierDrive->setHintText(tr("carrier drive"), "×");

m_carrierSync = new Knob(KnobType::Dark28, this);
m_carrierSync->move(knob_x[1] + knob_xy, knob_y[1] + knob_xy);
m_carrierSync->setHintText(tr("carrier sync"), "×");

m_carrierPulse = new Knob(KnobType::Dark28, this);
m_carrierPulse->move(knob_x[2] + knob_xy, knob_y[1] + knob_xy);
m_carrierPulse->setHintText(tr("carrier pulse"), "^");

m_modulatorDrive = new Knob(KnobType::Dark28, this);
m_modulatorDrive->move(knob_x[0] + knob_xy, knob_y[2] + knob_xy);
m_modulatorDrive->setHintText(tr("modulator drive"), "×");

m_modulatorSync = new Knob(KnobType::Dark28, this);
m_modulatorSync->move(knob_x[1] + knob_xy, knob_y[2] + knob_xy);
m_modulatorSync->setHintText(tr("modulator sync"), "×");

m_modulatorPulse = new Knob(KnobType::Dark28, this);
m_modulatorPulse->move(knob_x[2] + knob_xy, knob_y[2] + knob_xy);
m_modulatorPulse->setHintText(tr("modulator pulse"), "^");
}

void gui::SynchroView::modelChanged()
{
Synchro *model = castModel<Synchro>();
m_carrierWaveform->setModel(&model->m_carrierWaveform);
m_modulatorWaveform->setModel(&model->m_modulatorWaveform);
m_resultingWaveform->setModel(&model->m_resultingWaveform);
m_modAmt->setModel(&model->m_modAmt);
m_modScale->setModel(&model->m_modScale);
m_harmonics->setModel(&model->m_harmonics);
m_octaveRatio->setModel(&model->m_octaveRatio);
m_carrierDrive->setModel(&model->m_carrier.drive);
m_carrierSync->setModel(&model->m_carrier.sync);
m_carrierPulse->setModel(&model->m_carrier.pulse);
m_modulatorDrive->setModel(&model->m_modulator.drive);
m_modulatorSync->setModel(&model->m_modulator.sync);
m_modulatorPulse->setModel(&model->m_modulator.pulse);
}

} // namespace lmms
Loading
Loading