feat: beat-sync LFOs and delay to host BPM

This commit is contained in:
Armin 2026-07-14 01:18:46 +02:00
commit 5cc704c1d1
5 changed files with 250 additions and 110 deletions

View file

@ -1,6 +1,33 @@
#include "PluginProcessor.h"
#include "PluginEditor.h"
// Beat-sync multipliers and their display labels (period = multiplier * beat).
static const float beatValues[] = {0.25f, 0.3333333f, 0.5f, 0.75f, 1.0f, 1.5f, 2.0f};
static const char* beatLabels[] = {"1/4", "1/3", "1/2", "3/4", "1.0", "1.5", "2.0"};
static const int numBeats = 7;
// Halftone transpose menu: "0" plus -12..-1 and +1..+12.
const juce::StringArray& getSemitoneChoices() {
static const juce::StringArray s = [] {
juce::StringArray a;
a.add("0");
for (int i = 12; i >= 1; --i) a.add("-" + juce::String(i));
for (int i = 1; i <= 12; ++i) a.add("+" + juce::String(i));
return a;
}();
return s;
}
static int semitoneFromIndex(int idx) {
if (idx <= 0) return 0;
if (idx <= 12) return -(13 - idx); // idx 1..12 -> -12..-1
return idx - 12; // idx 13..24 -> +1..+12
}
static juce::String beatValueToText(float v, int) {
int i = juce::jlimit(0, numBeats - 1, juce::roundToInt(v));
return juce::String(beatLabels[i]);
}
ChromaFlockProcessor::ChromaFlockProcessor()
: AudioProcessor(BusesProperties()
.withOutput("Output", juce::AudioChannelSet::stereo(), true)),
@ -153,6 +180,14 @@ juce::AudioProcessorValueTreeState::ParameterLayout ChromaFlockProcessor::create
layout.add(std::make_unique<juce::AudioParameterChoice>(
juce::ParameterID{"lfo1Dest", 1}, "LFO1 Dest",
juce::StringArray{"Filter", "OSC1", "OSC2", "Both OSC"}, 0));
layout.add(std::make_unique<juce::AudioParameterChoice>(
juce::ParameterID{"lfo1Sync", 1}, "LFO1 Sync",
juce::StringArray{"Sync Off", "Sync On"}, 0));
layout.add(std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"lfo1Beat", 1}, "LFO1 Beat",
juce::NormalisableRange<float>(0.0f, static_cast<float>(numBeats - 1), 1.0f), 4.0f,
juce::AudioParameterFloatAttributes()
.withStringFromValueFunction(beatValueToText)));
// LFO 2
layout.add(std::make_unique<juce::AudioParameterFloat>(
@ -166,6 +201,14 @@ juce::AudioProcessorValueTreeState::ParameterLayout ChromaFlockProcessor::create
layout.add(std::make_unique<juce::AudioParameterChoice>(
juce::ParameterID{"lfo2Dest", 1}, "LFO2 Dest",
juce::StringArray{"Filter", "OSC1", "OSC2", "Both OSC"}, 0));
layout.add(std::make_unique<juce::AudioParameterChoice>(
juce::ParameterID{"lfo2Sync", 1}, "LFO2 Sync",
juce::StringArray{"Sync Off", "Sync On"}, 0));
layout.add(std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"lfo2Beat", 1}, "LFO2 Beat",
juce::NormalisableRange<float>(0.0f, static_cast<float>(numBeats - 1), 1.0f), 4.0f,
juce::AudioParameterFloatAttributes()
.withStringFromValueFunction(beatValueToText)));
// DELAY
layout.add(std::make_unique<juce::AudioParameterFloat>(
@ -175,6 +218,14 @@ juce::AudioProcessorValueTreeState::ParameterLayout ChromaFlockProcessor::create
juce::ParameterID{"delayFeedback", 1}, "Delay Feedback", zeroOne, 0.3f));
layout.add(std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"delayMix", 1}, "Delay Mix", zeroOne, 0.25f));
layout.add(std::make_unique<juce::AudioParameterChoice>(
juce::ParameterID{"delaySync", 1}, "Delay Sync",
juce::StringArray{"Sync Off", "Sync On"}, 0));
layout.add(std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"delayBeat", 1}, "Delay Beat",
juce::NormalisableRange<float>(0.0f, static_cast<float>(numBeats - 1), 1.0f), 4.0f,
juce::AudioParameterFloatAttributes()
.withStringFromValueFunction(beatValueToText)));
// REVERB
layout.add(std::make_unique<juce::AudioParameterFloat>(
@ -210,6 +261,18 @@ void ChromaFlockProcessor::updateVoiceParameters() {
if (auto* voice = dynamic_cast<SubtractiveVoice*>(synth.getVoice(i))) {
float pbRange = getParam("pitchBendRange");
voice->setPitchBend(getPitchBend() * pbRange);
float l1Rate = getParam("lfo1Rate");
if (getParam("lfo1Sync") > 0.5f) {
int bi = juce::jlimit(0, numBeats - 1, juce::roundToInt(getParam("lfo1Beat")));
l1Rate = static_cast<float>(currentBpm) / (60.0f * beatValues[bi]);
}
float l2Rate = getParam("lfo2Rate");
if (getParam("lfo2Sync") > 0.5f) {
int bi = juce::jlimit(0, numBeats - 1, juce::roundToInt(getParam("lfo2Beat")));
l2Rate = static_cast<float>(currentBpm) / (60.0f * beatValues[bi]);
}
voice->setParameters(
static_cast<Waveform>(static_cast<int>(getParam("osc1Wave"))),
static_cast<Waveform>(static_cast<int>(getParam("osc2Wave"))),
@ -238,22 +301,36 @@ void ChromaFlockProcessor::updateVoiceParameters() {
getParam("pan"),
getParam("drive"),
getParam("masterLevel"),
getParam("lfo1Rate"),
getParam("lfo1Depth"),
static_cast<int>(getParam("lfo1Shape")),
static_cast<int>(getParam("lfo1Dest")),
getParam("lfo2Rate"),
getParam("lfo2Depth"),
static_cast<int>(getParam("lfo2Shape")),
static_cast<int>(getParam("lfo2Dest"))
l1Rate,
getParam("lfo1Depth"),
static_cast<int>(getParam("lfo1Shape")),
static_cast<int>(getParam("lfo1Dest")),
l2Rate,
getParam("lfo2Depth"),
static_cast<int>(getParam("lfo2Shape")),
static_cast<int>(getParam("lfo2Dest"))
);
}
}
}
int ChromaFlockProcessor::getTransposeSemitones() const {
auto* op = apvts.getRawParameterValue("octaveTranspose");
auto* sp = apvts.getRawParameterValue("semitoneTranspose");
int octVal = (op != nullptr ? juce::roundToInt(op->load()) : 3) - 3; // index 0..6 -> -3..+3
int semiVal = (sp != nullptr ? semitoneFromIndex(juce::roundToInt(sp->load())) : 0);
return octVal * 12 + semiVal;
}
void ChromaFlockProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiMessages) {
juce::ScopedNoDenormals noDenormals;
if (auto* playHead = getPlayHead()) {
juce::AudioPlayHead::CurrentPositionInfo pos;
if (playHead->getCurrentPosition(pos) && pos.bpm > 0.0)
currentBpm = pos.bpm;
}
buffer.clear();
updateVoiceParameters();
@ -263,7 +340,16 @@ void ChromaFlockProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::
else if (metadata.getMessage().isNoteOff())
activeNotes[metadata.getMessage().getNoteNumber()].store(false, std::memory_order_relaxed);
synth.renderNextBlock(buffer, midiMessages, 0, buffer.getNumSamples());
int transpose = getTransposeSemitones();
juce::MidiBuffer transposed;
for (const auto metadata : midiMessages) {
auto msg = metadata.getMessage();
if (msg.isNoteOn() || msg.isNoteOff())
msg.setNoteNumber(juce::jlimit(0, 127, msg.getNoteNumber() + transpose));
transposed.addEvent(msg, metadata.samplePosition);
}
synth.renderNextBlock(buffer, transposed, 0, buffer.getNumSamples());
// Read FX params
auto getParam = [&](const juce::String& id) -> float {
@ -287,7 +373,12 @@ void ChromaFlockProcessor::processBlock(juce::AudioBuffer<float>& buffer, juce::
float panRate = getParam("autoPanRate");
float panDepth = getParam("autoPanDepth");
delay.setParameters(getParam("delayTime"), getParam("delayFeedback"), getParam("delayMix"));
float delayTimeMs = getParam("delayTime");
if (getParam("delaySync") > 0.5f) {
int bi = juce::jlimit(0, numBeats - 1, juce::roundToInt(getParam("delayBeat")));
delayTimeMs = beatValues[bi] * 60000.0f / static_cast<float>(currentBpm);
}
delay.setParameters(delayTimeMs, getParam("delayFeedback"), getParam("delayMix"));
reverbFX.setParameters(getParam("reverbSize"), getParam("reverbDamping"), 0.8f, getParam("reverbMix"));
// FX processing: distortion → compression → auto-pan