Files
biggy 3be1e42242 refactor(engine): add thread-safe control capture and sub-block MIDI
Introduce a CriticalSection controlLock separating control-thread
state (matrix, macros, controlLfos) from audio-thread snapshots
(audioMatrix, audioMacros, lfos). captureControls() copies under
lock at the start of each processBlock; the audio thread never
touches control state directly. Cache parameter values in an
unordered_map keyed by string_view to avoid per-sample APVTS lookups.

Prebuild wavetables in the constructor instead of lazy allocation.
Resize mixBuffer only in prepare(), not per block. Render MIDI
events at their sample positions using sub-block rendering with
renderUntil(), so notes start at the correct sub-sample offset.

Make RaveController non-destructive: replace APVTS mutation with a
static apply() that boosts the RenderContext and FX slots for the
current block only. Remove the snapshot/restore machinery.

Make currentProgram atomic. Validate state I/O: check XML tag,
clamp program index, sanitize LFO shape data, and use
beginChangeGesture/endChangeGesture for RAVE toggle. Reset
parameters to defaults before loading preset values.
2026-09-09 14:33:30 +02:00

367 lines
13 KiB
C++

#include "Engine.h"
#include "RAVEButton.h"
namespace serum
{
namespace
{
inline float limit (float x) noexcept
{
const float ax = std::fabs (x);
if (ax < 0.8f)
return x;
const float over = ax - 0.8f;
const float clipped = 0.8f + std::tanh (over) * 0.2f;
return std::copysign (clipped, x);
}
}
Engine::Engine()
{
wavetables.prebuild();
audioMatrix.connections.reserve (ModulationMatrix::kMaxConnections);
for (auto& assignments : audioMacros.assignments)
assignments.reserve (MacroControls::kMaxAssignments);
}
void Engine::prepare (double sampleRate, int maxBlockSize, juce::AudioProcessorValueTreeState& apvts)
{
const juce::ScopedLock lock (controlLock);
sr = sampleRate;
blockSize = juce::jmax (1, maxBlockSize);
parameterValues.clear();
for (auto* parameter : apvts.processor.getParameters())
if (auto* ranged = dynamic_cast<juce::RangedAudioParameter*> (parameter))
if (auto* raw = apvts.getRawParameterValue (ranged->paramID))
parameterValues.emplace (std::string_view (ranged->paramID.toRawUTF8()),
ParameterValue { raw, raw->load() });
for (auto& voice : voices)
voice.prepare (sampleRate, blockSize);
for (auto& lfo : lfos)
lfo.prepare (sampleRate);
fx.prepare (sampleRate, blockSize);
mixBuffer.setSize (2, blockSize, false, false, true);
reset();
captureControls();
}
void Engine::reset()
{
for (auto& voice : voices)
voice.reset();
for (auto& lfo : lfos)
lfo.reset();
fx.reset();
pitchBend = 0.0f;
modWheel = 0.0f;
activeVoiceCount.store (0, std::memory_order_relaxed);
mixBuffer.clear();
}
void Engine::captureControls()
{
const juce::ScopedTryLock lock (controlLock);
if (! lock.isLocked())
return;
audioMatrix.connections.assign (matrix.connections.begin(),
matrix.connections.begin() + juce::jmin (matrix.size(), ModulationMatrix::kMaxConnections));
for (int i = 0; i < kNumMacros; ++i)
{
const auto& source = macros.assignments[(size_t) i];
auto& dest = audioMacros.assignments[(size_t) i];
dest.assign (source.begin(), source.begin() + juce::jmin ((int) source.size(), MacroControls::kMaxAssignments));
}
for (int i = 0; i < kNumLfos; ++i)
lfos[(size_t) i].setShapeData (controlLfos[(size_t) i].getShapeData(),
controlLfos[(size_t) i].getShapeSteps());
for (auto& entry : parameterValues)
{
const float value = entry.second.source->load (std::memory_order_relaxed);
entry.second.value = std::isfinite (value) ? juce::jlimit (0.0f, 1.0f, value) : 0.0f;
}
}
void Engine::setLfoShapeData (int index, const std::vector<float>& data, int steps)
{
const juce::ScopedLock lock (controlLock);
index = juce::jlimit (0, kNumLfos - 1, index);
controlLfos[(size_t) index].setShapeData (data, steps);
}
float Engine::v (const char* id) const
{
const auto it = parameterValues.find (id);
return it != parameterValues.end() ? it->second.value : 0.0f;
}
int Engine::vic (const char* id, int maxValue) const
{
return juce::jlimit (0, maxValue, (int) std::llround (v (id) * maxValue));
}
SynthVoice* Engine::findFreeVoice()
{
for (auto& voice : voices)
if (! voice.isActive())
return &voice;
return nullptr;
}
SynthVoice* Engine::stealVoice()
{
// Prefer stealing an already-released voice, then the oldest active one.
SynthVoice* best = nullptr;
juce::uint64 bestId = std::numeric_limits<juce::uint64>::max();
for (auto& voice : voices)
if (voice.isActive() && voice.isReleased() && voice.getNoteId() < bestId)
{
best = &voice;
bestId = voice.getNoteId();
}
if (best != nullptr)
return best;
for (auto& voice : voices)
if (voice.isActive() && voice.getNoteId() < bestId)
{
best = &voice;
bestId = voice.getNoteId();
}
return best != nullptr ? best : &voices[0];
}
void Engine::noteOn (int noteNumber, float velocity01)
{
SynthVoice* voice = findFreeVoice();
if (voice == nullptr)
voice = stealVoice();
const double freq = juce::MidiMessage::getMidiNoteInHertz (noteNumber);
voice->noteOn (noteNumber, juce::jlimit (0.0f, 1.0f, velocity01), freq, (juce::uint32) (++noteCounter));
}
void Engine::noteOff (int noteNumber)
{
for (auto& voice : voices)
if (voice.isActive() && voice.getNote() == noteNumber && ! voice.isReleased())
voice.noteOff();
}
void Engine::allNotesOff()
{
for (auto& voice : voices)
if (voice.isActive())
voice.noteOff();
}
void Engine::readOscParams (const paramIds::Oscillator& ids, OscParams& o) const
{
o.enabled = v (ids.on) > 0.5f;
o.wave = vic (ids.wave, kNumWavetables - 1);
o.wtPos = v (ids.wtPos);
o.warp = vic (ids.warp, (int) WarpMode::Count - 1);
o.warpAmt = v (ids.warpAmt);
o.coarse = vic (ids.coarse, 48) - 24;
o.fine = vic (ids.fine, 200) - 100;
o.level = v (ids.level);
o.pan = v (ids.pan) * 2.0f - 1.0f;
o.unison = 1 + vic (ids.unison, kMaxUnison - 1);
o.detune = v (ids.detune);
o.spread = v (ids.spread);
o.phase = v (ids.phase);
o.randPhase = v (ids.randPhase);
}
void Engine::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midi,
juce::AudioProcessorValueTreeState&, juce::AudioPlayHead* playhead)
{
const int n = buffer.getNumSamples();
const int numCh = buffer.getNumChannels();
captureControls();
// Tempo.
if (playhead != nullptr)
if (auto pos = playhead->getPosition())
if (auto tempo = pos->getBpm())
if (std::isfinite (*tempo) && *tempo > 0.0)
bpm = *tempo;
// Advance LFOs and capture their values at control-rate render boundaries.
for (int i = 0; i < kNumLfos; ++i)
{
auto& lfo = lfos[(size_t) i];
lfo.setTempo (bpm);
lfo.setParams (v (paramIds::lfoRate[i]), v (paramIds::lfoSync[i]) > 0.5f,
v (paramIds::lfoBeat[i]), vic (paramIds::lfoShape[i], (int) LfoShape::Count - 1),
v (paramIds::lfoPhase[i]), v (paramIds::lfoFade[i]), v (paramIds::lfoDelay[i]));
}
// Build the render context.
RenderContext ctx;
ctx.sampleRate = sr;
ctx.wavetables = &wavetables;
ctx.matrix = &audioMatrix;
ctx.macros = &audioMacros;
for (int i = 0; i < kNumMacros; ++i)
ctx.macroValues[i] = v (paramIds::macros[i]);
readOscParams (paramIds::oscillators[0], ctx.oscA);
readOscParams (paramIds::oscillators[1], ctx.oscB);
ctx.subOn = v (ids::subOn) > 0.5f;
ctx.subShape = vic (ids::subShape, (int) SubShape::Count - 1);
ctx.subOct = vic (ids::subOct, 2) - 2;
ctx.subLevel = v (ids::subLevel);
ctx.noiseOn = v (ids::noiseOn) > 0.5f;
ctx.noiseType = vic (ids::noiseType, (int) NoiseType::Count - 1);
ctx.noiseLevel = v (ids::noiseLevel);
ctx.filters.f1On = v (ids::f1On) > 0.5f;
ctx.filters.f1Type = vic (ids::f1Type, (int) FilterModel::Count - 1);
ctx.filters.f1Cutoff = v (ids::f1Cutoff);
ctx.filters.f1Res = v (ids::f1Res);
ctx.filters.f1Drive = v (ids::f1Drive);
ctx.filters.f1Key = v (ids::f1Key);
ctx.filters.f1Slope = vic (ids::f1Slope, 2);
ctx.filters.f2On = v (ids::f2On) > 0.5f;
ctx.filters.f2Type = vic (ids::f2Type, (int) FilterModel::Count - 1);
ctx.filters.f2Cutoff = v (ids::f2Cutoff);
ctx.filters.f2Res = v (ids::f2Res);
ctx.filters.f2Drive = v (ids::f2Drive);
ctx.filters.f2Key = v (ids::f2Key);
ctx.filters.f2Slope = vic (ids::f2Slope, 2);
ctx.filters.route = vic (ids::fRoute, (int) FilterRoute::Count - 1);
ctx.filters.mix = v (ids::fMix);
ctx.filters.out = v (ids::fOut) * 1.5f;
for (int i = 0; i < kNumEnvelopes; ++i)
{
ctx.envAttack[i] = v (paramIds::envAttack[i]);
ctx.envDecay[i] = v (paramIds::envDecay[i]);
ctx.envSustain[i] = v (paramIds::envSustain[i]);
ctx.envRelease[i] = v (paramIds::envRelease[i]);
ctx.envCurve[i] = v (paramIds::envCurve[i]);
}
// FX rack.
std::array<FxSlotParams, kNumFxSlots> slots;
for (int i = 0; i < kNumFxSlots; ++i)
{
auto& slot = slots[(size_t) i];
slot.type = vic (paramIds::fxType[i], (int) FxType::Count - 1);
slot.mix = v (paramIds::fxMix[i]);
slot.p[0] = v (paramIds::fxP1[i]);
slot.p[1] = v (paramIds::fxP2[i]);
slot.p[2] = v (paramIds::fxP3[i]);
slot.p[3] = v (paramIds::fxP4[i]);
}
if (v (ids::rave) > 0.5f)
RaveController::apply (ctx, slots.data(), kNumFxSlots);
int offset = 0;
auto renderUntil = [&] (int end)
{
while (offset < end)
{
const int count = juce::jmin (end - offset, juce::jmin (blockSize, 64));
ctx.modWheel = modWheel;
ctx.pitchBend = pitchBend;
for (int i = 0; i < kNumLfos; ++i)
ctx.lfoValues[i] = lfos[(size_t) i].getValue();
const SynthVoice* newest = nullptr;
for (const auto& voice : voices)
if (voice.isActive() && (newest == nullptr || voice.getNoteId() > newest->getNoteId()))
newest = &voice;
auto sourceValue = [&] (ModSource source)
{
const int index = (int) source;
if (source >= ModSource::Lfo1 && source <= ModSource::Lfo4)
return ctx.lfoValues[index - (int) ModSource::Lfo1];
if (source >= ModSource::Macro1 && source <= ModSource::Macro4)
return ctx.macroValues[index - (int) ModSource::Macro1];
if (source == ModSource::ModWheel) return modWheel;
if (source == ModSource::PitchBend) return pitchBend;
return newest != nullptr ? newest->getModulationValue (source) : 0.0f;
};
std::array<float, kNumModTargets> globalMod {};
for (const auto& connection : audioMatrix.connections)
if (! isPerVoiceTarget (connection.target))
{
float value = sourceValue (connection.source);
if (connection.bipolar && ! isBipolarSource (connection.source))
value = value * 2.0f - 1.0f;
globalMod[(size_t) connection.target] += value * connection.depth;
}
for (int i = 0; i < kNumMacros; ++i)
for (const auto& assignment : audioMacros.assignments[(size_t) i])
if (! isPerVoiceTarget (assignment.target))
globalMod[(size_t) assignment.target] += ctx.macroValues[i] * assignment.depth;
// Prepare the voice mix buffer.
juce::AudioBuffer<float> block (mixBuffer.getArrayOfWritePointers(), 2, count);
block.clear();
// Render all active voices into the mix buffer.
for (auto& voice : voices)
if (voice.isActive())
voice.render (block.getWritePointer (0), block.getWritePointer (1), count, ctx);
auto modulatedSlots = slots;
for (int i = 0; i < kNumFxSlots; ++i)
modulatedSlots[(size_t) i].mix = juce::jlimit (0.0f, 1.0f,
slots[(size_t) i].mix + globalMod[(size_t) ModTarget::Fx1Mix + (size_t) i]);
fx.process (block, modulatedSlots.data(), kNumFxSlots);
// Master + soft limiting.
const float master = juce::jlimit (0.0f, 1.0f, v (ids::master) + globalMod[(size_t) ModTarget::Master]);
for (int ch = 0; ch < numCh; ++ch)
{
float* dest = buffer.getWritePointer (ch, offset);
const float* src = block.getReadPointer (ch < 2 ? ch : 0);
for (int i = 0; i < count; ++i)
dest[i] = limit (src[i] * master);
}
for (auto& lfo : lfos)
lfo.advance (count);
offset += count;
}
};
// MIDI.
for (const auto meta : midi)
{
renderUntil (juce::jlimit (offset, n, meta.samplePosition));
const auto message = meta.getMessage();
if (message.isNoteOn())
noteOn (message.getNoteNumber(), message.getFloatVelocity());
else if (message.isNoteOff())
noteOff (message.getNoteNumber());
else if (message.isPitchWheel())
pitchBend = (message.getPitchWheelValue() - 8192) / 8192.0f;
else if (message.isAllSoundOff())
{
for (auto& voice : voices)
voice.reset();
fx.reset();
}
else if (message.isAllNotesOff())
allNotesOff();
else if (message.isController() && message.getControllerNumber() == 1)
modWheel = message.getControllerValue() / 127.0f;
}
renderUntil (n);
int active = 0;
for (const auto& voice : voices)
active += voice.isActive() ? 1 : 0;
activeVoiceCount.store (active, std::memory_order_relaxed);
}
} // namespace serum