From 3be1e42242a585307e8deca4add7ceae3f2675a7 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 9 Sep 2026 14:33:30 +0200 Subject: [PATCH] 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. --- Source/Engine.cpp | 438 ++++++++++++++++++++----------------- Source/Engine.h | 39 +++- Source/PluginProcessor.cpp | 140 +++++++++--- Source/PluginProcessor.h | 9 +- Source/RAVEButton.cpp | 97 ++------ Source/RAVEButton.h | 21 +- 6 files changed, 408 insertions(+), 336 deletions(-) diff --git a/Source/Engine.cpp b/Source/Engine.cpp index e5b7849..d9ad586 100644 --- a/Source/Engine.cpp +++ b/Source/Engine.cpp @@ -1,22 +1,11 @@ #include "Engine.h" +#include "RAVEButton.h" namespace serum { namespace { - inline float v (juce::AudioProcessorValueTreeState& apvts, const char* id) - { - if (auto* p = apvts.getRawParameterValue (id)) - return p->load(); - return 0.0f; - } - - inline int vic (juce::AudioProcessorValueTreeState& apvts, const char* id, int maxValue) - { - return juce::jlimit (0, maxValue, (int) std::llround (v (apvts, id) * maxValue)); - } - inline float limit (float x) noexcept { const float ax = std::fabs (x); @@ -26,47 +15,36 @@ namespace const float clipped = 0.8f + std::tanh (over) * 0.2f; return std::copysign (clipped, x); } - - const char* kEnvAttack[kNumEnvelopes] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A }; - const char* kEnvDecay[kNumEnvelopes] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D }; - const char* kEnvSustain[kNumEnvelopes]= { ids::env1S, ids::env2S, ids::env3S, ids::env4S }; - const char* kEnvRelease[kNumEnvelopes]= { ids::env1R, ids::env2R, ids::env3R, ids::env4R }; - const char* kEnvCurve[kNumEnvelopes] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve }; - - const char* kLfoRate[kNumLfos] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate }; - const char* kLfoSync[kNumLfos] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync }; - const char* kLfoBeat[kNumLfos] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat }; - const char* kLfoShape[kNumLfos] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape }; - const char* kLfoPhase[kNumLfos] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase }; - const char* kLfoFade[kNumLfos] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade }; - const char* kLfoDelay[kNumLfos] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay }; - - const char* kFxType[kNumFxSlots] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type, - ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type }; - const char* kFxMix[kNumFxSlots] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix, - ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix }; - const char* kFxP1[kNumFxSlots] = { ids::fx1P1, ids::fx2P1, ids::fx3P1, ids::fx4P1, - ids::fx5P1, ids::fx6P1, ids::fx7P1, ids::fx8P1 }; - const char* kFxP2[kNumFxSlots] = { ids::fx1P2, ids::fx2P2, ids::fx3P2, ids::fx4P2, - ids::fx5P2, ids::fx6P2, ids::fx7P2, ids::fx8P2 }; - const char* kFxP3[kNumFxSlots] = { ids::fx1P3, ids::fx2P3, ids::fx3P3, ids::fx4P3, - ids::fx5P3, ids::fx6P3, ids::fx7P3, ids::fx8P3 }; - const char* kFxP4[kNumFxSlots] = { ids::fx1P4, ids::fx2P4, ids::fx3P4, ids::fx4P4, - ids::fx5P4, ids::fx6P4, ids::fx7P4, ids::fx8P4 }; } -void Engine::prepare (double sampleRate, int maxBlockSize) +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 = maxBlockSize; + blockSize = juce::jmax (1, maxBlockSize); + parameterValues.clear(); + for (auto* parameter : apvts.processor.getParameters()) + if (auto* ranged = dynamic_cast (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, maxBlockSize); + voice.prepare (sampleRate, blockSize); for (auto& lfo : lfos) lfo.prepare (sampleRate); - fx.prepare (sampleRate, maxBlockSize); - mixBuffer.setSize (2, maxBlockSize, false, false, true); + fx.prepare (sampleRate, blockSize); + mixBuffer.setSize (2, blockSize, false, false, true); reset(); + captureControls(); } void Engine::reset() @@ -78,29 +56,57 @@ void Engine::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& data, int steps) { + const juce::ScopedLock lock (controlLock); index = juce::jlimit (0, kNumLfos - 1, index); - lfos[(size_t) index].setShapeData (data, steps); + controlLfos[(size_t) index].setShapeData (data, steps); } -int Engine::getActiveVoiceCount() const +float Engine::v (const char* id) const { - int count = 0; - for (const auto& v : voices) - if (v.isActive()) - ++count; - return count; + 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& v : voices) - if (! v.isActive()) - return &v; + for (auto& voice : voices) + if (! voice.isActive()) + return &voice; return nullptr; } @@ -110,20 +116,20 @@ SynthVoice* Engine::stealVoice() SynthVoice* best = nullptr; juce::uint64 bestId = std::numeric_limits::max(); - for (auto& v : voices) - if (v.isActive() && v.isReleased() && v.getNoteId() < bestId) + for (auto& voice : voices) + if (voice.isActive() && voice.isReleased() && voice.getNoteId() < bestId) { - best = &v; - bestId = v.getNoteId(); + best = &voice; + bestId = voice.getNoteId(); } if (best != nullptr) return best; - for (auto& v : voices) - if (v.isActive() && v.getNoteId() < bestId) + for (auto& voice : voices) + if (voice.isActive() && voice.getNoteId() < bestId) { - best = &v; - bestId = v.getNoteId(); + best = &voice; + bestId = voice.getNoteId(); } return best != nullptr ? best : &voices[0]; } @@ -140,181 +146,221 @@ void Engine::noteOn (int noteNumber, float velocity01) void Engine::noteOff (int noteNumber) { - for (auto& v : voices) - if (v.isActive() && v.getNote() == noteNumber && ! v.isReleased()) - v.noteOff(); + for (auto& voice : voices) + if (voice.isActive() && voice.getNote() == noteNumber && ! voice.isReleased()) + voice.noteOff(); } void Engine::allNotesOff() { - for (auto& v : voices) - if (v.isActive()) - v.noteOff(); + for (auto& voice : voices) + if (voice.isActive()) + voice.noteOff(); } -void Engine::readOscParams (juce::AudioProcessorValueTreeState& apvts, const char* prefix, OscParams& o) +void Engine::readOscParams (const paramIds::Oscillator& ids, OscParams& o) const { - const juce::String p = prefix; - auto g = [&] (const char* suffix) { return v (apvts, (p + suffix).toRawUTF8()); }; - - o.enabled = g ("On") > 0.5f; - o.wave = juce::jlimit (0, kNumWavetables - 1, (int) std::llround (g ("Wave") * (kNumWavetables - 1))); - o.wtPos = g ("WtPos"); - o.warp = (int) std::llround (g ("Warp") * 7.0f); - o.warpAmt = g ("WarpAmt"); - o.coarse = (int) std::llround (g ("Coarse") * 48.0f) - 24; - o.fine = (int) std::llround (g ("Fine") * 200.0f) - 100; - o.level = g ("Level"); - o.pan = g ("Pan") * 2.0f - 1.0f; - o.unison = 1 + (int) std::llround (g ("Unison") * 15.0f); - o.detune = g ("Detune"); - o.spread = g ("Spread"); - o.phase = g ("Phase"); - o.randPhase = g ("RandPh"); + 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& buffer, juce::MidiBuffer& midi, - juce::AudioProcessorValueTreeState& apvts, - juce::AudioPlayHead* playhead) + 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 b = pos->getBpm()) - bpm = *b; + if (auto tempo = pos->getBpm()) + if (std::isfinite (*tempo) && *tempo > 0.0) + bpm = *tempo; - // MIDI. - for (const auto meta : midi) - { - const auto m = meta.getMessage(); - if (m.isNoteOn() && m.getVelocity() > 0) - noteOn (m.getNoteNumber(), m.getFloatVelocity()); - else if (m.isNoteOff() || (m.isNoteOn() && m.getVelocity() == 0)) - noteOff (m.getNoteNumber()); - else if (m.isPitchWheel()) - pitchBend = (m.getPitchWheelValue() - 8192) / 8192.0f; - else if (m.isController()) - { - if (m.getControllerNumber() == 1) - modWheel = m.getControllerValue() / 127.0f; - else if (m.getControllerNumber() == 120 || m.getControllerNumber() == 123) - allNotesOff(); - } - else if (m.isAllNotesOff() || m.isAllSoundOff()) - allNotesOff(); - } - - // Prepare the voice mix buffer. - mixBuffer.setSize (2, n, false, false, true); - mixBuffer.clear(); - float* mixL = mixBuffer.getWritePointer (0); - float* mixR = mixBuffer.getWritePointer (1); - - // Advance LFOs and capture their values (control rate). - float lfoValues[kNumLfos]; + // Advance LFOs and capture their values at control-rate render boundaries. for (int i = 0; i < kNumLfos; ++i) { - lfos[(size_t) i].setTempo (bpm); - lfos[(size_t) i].setParams (v (apvts, kLfoRate[i]), - v (apvts, kLfoSync[i]) > 0.5f, - v (apvts, kLfoBeat[i]), - vic (apvts, kLfoShape[i], 6), - v (apvts, kLfoPhase[i]), - v (apvts, kLfoFade[i]), - v (apvts, kLfoDelay[i])); - for (int s = 0; s < n; ++s) - lfos[(size_t) i].process(); - lfoValues[i] = lfos[(size_t) i].getValue(); + 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])); } - const float macroValues[kNumMacros] = { v (apvts, ids::macro1), v (apvts, ids::macro2), - v (apvts, ids::macro3), v (apvts, ids::macro4) }; - // Build the render context. RenderContext ctx; ctx.sampleRate = sr; ctx.wavetables = &wavetables; - for (int i = 0; i < kNumLfos; ++i) ctx.lfoValues[i] = lfoValues[i]; - for (int i = 0; i < kNumMacros; ++i) ctx.macroValues[i] = macroValues[i]; - ctx.modWheel = modWheel; - ctx.pitchBend = pitchBend; - ctx.pitchBendRange = 2.0f; - ctx.matrix = &matrix; - ctx.macros = ¯os; + ctx.matrix = &audioMatrix; + ctx.macros = &audioMacros; + for (int i = 0; i < kNumMacros; ++i) + ctx.macroValues[i] = v (paramIds::macros[i]); - readOscParams (apvts, "oscA", ctx.oscA); - readOscParams (apvts, "oscB", ctx.oscB); + readOscParams (paramIds::oscillators[0], ctx.oscA); + readOscParams (paramIds::oscillators[1], ctx.oscB); - ctx.subOn = v (apvts, ids::subOn) > 0.5f; - ctx.subShape = vic (apvts, ids::subShape, 1); - ctx.subOct = vic (apvts, ids::subOct, 2) - 2; - ctx.subLevel = v (apvts, ids::subLevel); + 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.noiseOn = v (apvts, ids::noiseOn) > 0.5f; - ctx.noiseType = vic (apvts, ids::noiseType, 1); - ctx.noiseLevel = v (apvts, ids::noiseLevel); - - ctx.filters.f1On = v (apvts, ids::f1On) > 0.5f; - ctx.filters.f1Type = vic (apvts, ids::f1Type, 6); - ctx.filters.f1Cutoff = v (apvts, ids::f1Cutoff); - ctx.filters.f1Res = v (apvts, ids::f1Res); - ctx.filters.f1Drive = v (apvts, ids::f1Drive); - ctx.filters.f1Key = v (apvts, ids::f1Key); - ctx.filters.f1Slope = vic (apvts, ids::f1Slope, 2); - - ctx.filters.f2On = v (apvts, ids::f2On) > 0.5f; - ctx.filters.f2Type = vic (apvts, ids::f2Type, 6); - ctx.filters.f2Cutoff = v (apvts, ids::f2Cutoff); - ctx.filters.f2Res = v (apvts, ids::f2Res); - ctx.filters.f2Drive = v (apvts, ids::f2Drive); - ctx.filters.f2Key = v (apvts, ids::f2Key); - ctx.filters.f2Slope = vic (apvts, ids::f2Slope, 2); - - ctx.filters.route = vic (apvts, ids::fRoute, 2); - ctx.filters.mix = v (apvts, ids::fMix); - ctx.filters.out = v (apvts, ids::fOut) * 1.5f; + 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 (apvts, kEnvAttack[i]); - ctx.envDecay[i] = v (apvts, kEnvDecay[i]); - ctx.envSustain[i] = v (apvts, kEnvSustain[i]); - ctx.envRelease[i] = v (apvts, kEnvRelease[i]); - ctx.envCurve[i] = v (apvts, kEnvCurve[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]); } - // Render all active voices into the mix buffer. - for (auto& voice : voices) - if (voice.isActive()) - voice.render (mixL, mixR, n, ctx); - // FX rack. - FxSlotParams slots[kNumFxSlots]; + std::array slots; for (int i = 0; i < kNumFxSlots; ++i) { - slots[i].type = juce::jlimit (0, (int) FxType::Count - 1, (int) std::llround (v (apvts, kFxType[i]) * ((int) FxType::Count - 1))); - slots[i].mix = v (apvts, kFxMix[i]); - slots[i].p[0] = v (apvts, kFxP1[i]); - slots[i].p[1] = v (apvts, kFxP2[i]); - slots[i].p[2] = v (apvts, kFxP3[i]); - slots[i].p[3] = v (apvts, kFxP4[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); - fx.process (mixBuffer, slots, kNumFxSlots); - - // Master + soft limiting. - const float master = v (apvts, ids::master); - - for (int ch = 0; ch < numCh; ++ch) + int offset = 0; + auto renderUntil = [&] (int end) { - float* dest = buffer.getWritePointer (ch); - const float* src = mixBuffer.getReadPointer (ch < 2 ? ch : 0); - for (int i = 0; i < n; ++i) - dest[i] = limit (src[i] * master); + 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 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 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 diff --git a/Source/Engine.h b/Source/Engine.h index 322a6c7..8a5f8ea 100644 --- a/Source/Engine.h +++ b/Source/Engine.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include "Params.h" #include "Wavetable.h" #include "SynthVoice.h" @@ -19,7 +21,8 @@ namespace serum class Engine { public: - void prepare (double sampleRate, int blockSize); + Engine(); + void prepare (double sampleRate, int blockSize, juce::AudioProcessorValueTreeState& apvts); void reset(); void processBlock (juce::AudioBuffer& buffer, @@ -32,22 +35,31 @@ public: void noteOff (int noteNumber); void allNotesOff(); - // Modulation / DSP accessors (read-only for the GUI). - ModulationMatrix& getMatrix() { return matrix; } - MacroControls& getMacros() { return macros; } - WavetableLibrary& getWavetables() { return wavetables; } - const std::array& getLfos() const { return lfos; } + // Control-state accessors: hold getControlLock() while reading or editing. + const juce::CriticalSection& getControlLock() const { return controlLock; } + ModulationMatrix& getMatrix() { return matrix; } + MacroControls& getMacros() { return macros; } + const std::array& getLfos() const { return controlLfos; } + const WavetableLibrary& getWavetables() const { return wavetables; } void setLfoShapeData (int index, const std::vector& data, int steps); - int getActiveVoiceCount() const; + int getActiveVoiceCount() const { return activeVoiceCount.load (std::memory_order_relaxed); } private: std::array voices; - std::array lfos; + std::array lfos, controlLfos; WavetableLibrary wavetables; FXProcessor fx; - ModulationMatrix matrix; - MacroControls macros; + ModulationMatrix matrix, audioMatrix; + MacroControls macros, audioMacros; + juce::CriticalSection controlLock; + + struct ParameterValue + { + std::atomic* source; + float value; + }; + std::unordered_map parameterValues; double sr = 44100.0; int blockSize = 512; @@ -55,13 +67,16 @@ private: float pitchBend = 0.0f; float modWheel = 0.0f; juce::uint64 noteCounter = 0; + std::atomic activeVoiceCount { 0 }; juce::AudioBuffer mixBuffer; SynthVoice* findFreeVoice(); SynthVoice* stealVoice(); - - void readOscParams (juce::AudioProcessorValueTreeState& apvts, const char* prefix, OscParams& out); + void captureControls(); + float v (const char* id) const; + int vic (const char* id, int maxValue) const; + void readOscParams (const paramIds::Oscillator& ids, OscParams& out) const; }; } // namespace serum diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index b4ccd4c..b2e6298 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -176,7 +176,7 @@ juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::crea // --------------------------------------------------------------------------- void SerumAltAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) { - engine.prepare (sampleRate, samplesPerBlock); + engine.prepare (sampleRate, samplesPerBlock, parameters); } void SerumAltAudioProcessor::releaseResources() @@ -205,14 +205,13 @@ int SerumAltAudioProcessor::getNumPrograms() int SerumAltAudioProcessor::getCurrentProgram() { - return currentProgram; + return currentProgram.load(); } void SerumAltAudioProcessor::setCurrentProgram (int index) { - index = juce::jlimit (0, getNumPrograms() - 1, index); - loadFactoryPreset (index); - currentProgram = index; + if (getNumPrograms() > 0) + loadFactoryPreset (juce::jlimit (0, getNumPrograms() - 1, index)); } const juce::String SerumAltAudioProcessor::getProgramName (int index) @@ -238,24 +237,36 @@ void SerumAltAudioProcessor::loadFactoryPreset (int index) if (index < 0 || index >= (int) presets.size()) return; + const juce::ScopedLock lock (engine.getControlLock()); const FactoryPreset& preset = presets[(size_t) index]; - - // RAVE should start off for a freshly loaded preset. - rave.resetSnapshot(); - if (auto* raveParam = parameters.getParameter (ids::rave)) - raveParam->setValueNotifyingHost (0.0f); + const auto* uiScaleParam = parameters.getParameter (ids::uiScale); + for (auto* param : getParameters()) + if (param != nullptr && param != uiScaleParam) + param->setValueNotifyingHost (param->getDefaultValue()); for (const auto& kv : preset.params) if (auto* param = parameters.getParameter (kv.first)) - param->setValueNotifyingHost (kv.second); + if (param != uiScaleParam && std::isfinite (kv.second)) + param->setValueNotifyingHost (juce::jlimit (0.0f, 1.0f, kv.second)); - engine.getMatrix().clear(); + // RAVE should start off for a freshly loaded preset. + if (auto* raveParam = parameters.getParameter (ids::rave)) + raveParam->setValueNotifyingHost (0.0f); + + auto& matrix = engine.getMatrix(); + matrix.clear(); for (const auto& mod : preset.mods) - engine.getMatrix().addConnection (mod.source, mod.target, mod.depth, mod.bipolar); + if (! matrix.addConnection (mod.source, mod.target, mod.depth, mod.bipolar)) + continue; - engine.getMacros().clear(); + auto& macros = engine.getMacros(); + macros.clear(); for (const auto& ma : preset.macroAssigns) - engine.getMacros().addAssignment (ma.macro, ma.target, ma.depth); + if (! macros.addAssignment (ma.macro, ma.target, ma.depth)) + continue; + + restoreLfoShapesFromState ({}); + currentProgram.store (index); } // --------------------------------------------------------------------------- @@ -263,25 +274,36 @@ void SerumAltAudioProcessor::loadFactoryPreset (int index) // --------------------------------------------------------------------------- void SerumAltAudioProcessor::setRaveEnabled (bool enabled) { - rave.setEnabled (enabled, parameters); + const juce::ScopedLock lock (engine.getControlLock()); if (auto* raveParam = parameters.getParameter (ids::rave)) + { + raveParam->beginChangeGesture(); raveParam->setValueNotifyingHost (enabled ? 1.0f : 0.0f); + raveParam->endChangeGesture(); + } } bool SerumAltAudioProcessor::isRaveEnabled() const { - return rave.isEnabled(); + if (auto* raveParam = parameters.getRawParameterValue (ids::rave)) + return raveParam->load() > 0.5f; + return false; } int SerumAltAudioProcessor::getUiScaleIndex() const { if (auto* p = parameters.getRawParameterValue (ids::uiScale)) - return juce::jlimit (0, 4, (int) std::llround (p->load() * 4.0f)); + { + const float value = p->load(); + if (std::isfinite (value)) + return (int) std::llround (juce::jlimit (0.0f, 1.0f, value) * 4.0f); + } return 1; } void SerumAltAudioProcessor::setUiScaleIndex (int index) { + const juce::ScopedLock lock (engine.getControlLock()); index = juce::jlimit (0, 4, index); if (auto* p = parameters.getParameter (ids::uiScale)) p->setValueNotifyingHost ((float) index / 4.0f); @@ -298,7 +320,15 @@ float SerumAltAudioProcessor::getUiScale() const // --------------------------------------------------------------------------- void SerumAltAudioProcessor::getStateInformation (juce::MemoryBlock& destData) { + const juce::ScopedLock lock (engine.getControlLock()); auto state = parameters.copyState(); + for (int i = state.getNumChildren(); --i >= 0;) + { + const auto child = state.getChild (i); + if (child.hasType ("MODMATRIX") || child.hasType ("MACROS") || child.hasType ("LFOSHAPES")) + state.removeChild (i, nullptr); + } + state.setProperty ("currentProgram", currentProgram.load(), nullptr); state.appendChild (engine.getMatrix().toValueTree(), nullptr); state.appendChild (engine.getMacros().toValueTree(), nullptr); saveLfoShapesToState (state); @@ -310,39 +340,47 @@ void SerumAltAudioProcessor::getStateInformation (juce::MemoryBlock& destData) void SerumAltAudioProcessor::setStateInformation (const void* data, int sizeInBytes) { + if (data == nullptr || sizeInBytes <= 0) + return; std::unique_ptr xml (getXmlFromBinary (data, sizeInBytes)); - if (xml == nullptr) + if (xml == nullptr || ! xml->hasTagName ("SerumAlt")) return; juce::ValueTree state = juce::ValueTree::fromXml (*xml); - if (! state.isValid()) + if (! state.hasType ("SerumAlt")) return; + const juce::ScopedLock lock (engine.getControlLock()); + // A persisted RAVE toggle is rendered non-destructively, so retain it. parameters.replaceState (state); engine.getMatrix().fromValueTree (state.getChildWithName ("MODMATRIX")); engine.getMacros().fromValueTree (state.getChildWithName ("MACROS")); restoreLfoShapesFromState (state); - // A persisted RAVE toggle has no live snapshot, so start it off. - rave.resetSnapshot(); - if (auto* raveParam = parameters.getParameter (ids::rave)) - raveParam->setValueNotifyingHost (0.0f); + const double savedProgram = (double) state.getProperty ("currentProgram", 0); + currentProgram.store (std::isfinite (savedProgram) + ? (int) juce::jlimit (0.0, (double) juce::jmax (0, getNumPrograms() - 1), savedProgram) : 0); } void SerumAltAudioProcessor::saveLfoShapesToState (juce::ValueTree& state) const { + const juce::ScopedLock lock (engine.getControlLock()); juce::ValueTree tree ("LFOSHAPES"); for (int i = 0; i < kNumLfos; ++i) { - const auto& data = engine.getLfos()[(size_t) i].getShapeData(); + const auto& source = engine.getLfos()[(size_t) i]; + const auto& data = source.getShapeData(); juce::ValueTree lfo ("LFO"); lfo.setProperty ("index", i, nullptr); - lfo.setProperty ("steps", engine.getLfos()[(size_t) i].getShapeSteps(), nullptr); + lfo.setProperty ("steps", juce::jlimit (2, LFO::kShapePoints, source.getShapeSteps()), nullptr); juce::Array arr; - for (float vv : data) - arr.add (vv); - lfo.setProperty ("data", juce::var (arr), nullptr); + for (int point = 0; point < juce::jmin (LFO::kShapePoints, (int) data.size()); ++point) + { + const float value = data[(size_t) point]; + arr.add (std::isfinite (value) ? juce::jlimit (-1.0f, 1.0f, value) : 0.0f); + } + lfo.setProperty ("data", juce::JSON::toString (juce::var (arr), true), nullptr); tree.appendChild (lfo, nullptr); } state.appendChild (tree, nullptr); @@ -350,23 +388,57 @@ void SerumAltAudioProcessor::saveLfoShapesToState (juce::ValueTree& state) const void SerumAltAudioProcessor::restoreLfoShapesFromState (const juce::ValueTree& state) { + const juce::ScopedLock lock (engine.getControlLock()); + std::vector defaultShape ((size_t) LFO::kShapePoints); + for (int point = 0; point < LFO::kShapePoints; ++point) + defaultShape[(size_t) point] = (point % 2 == 0) ? 1.0f : -1.0f; + for (int index = 0; index < kNumLfos; ++index) + engine.setLfoShapeData (index, defaultShape, 16); + const juce::ValueTree tree = state.getChildWithName ("LFOSHAPES"); if (! tree.isValid()) return; + std::array restored {}; for (const auto& lfo : tree) { if (! lfo.hasType ("LFO")) continue; - const int index = juce::jlimit (0, kNumLfos - 1, (int) lfo.getProperty ("index", 0)); - const int steps = (int) lfo.getProperty ("steps", 16); + const double savedIndex = (double) lfo.getProperty ("index", -1); + if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumLfos + || std::floor (savedIndex) != savedIndex) + continue; + const int index = (int) savedIndex; + if (restored[(size_t) index]) + continue; + const double savedSteps = (double) lfo.getProperty ("steps", 16); + const int steps = std::isfinite (savedSteps) + ? (int) juce::jlimit (2.0, (double) LFO::kShapePoints, savedSteps) : 16; + juce::var shape = lfo.getProperty ("data"); + if (shape.isString()) + { + const auto text = shape.toString(); + if (text.length() > 8192) + continue; + shape = juce::JSON::parse (text); + } + const auto* arr = shape.getArray(); + if (arr == nullptr || arr->isEmpty()) + continue; + const int numPoints = juce::jmin (LFO::kShapePoints, arr->size()); std::vector data; - if (auto* arr = lfo.getProperty ("data").getArray()) - for (const auto& vv : *arr) - data.push_back ((float) vv); + data.reserve ((size_t) numPoints); + for (int point = 0; point < numPoints; ++point) + { + const auto& savedValue = arr->getReference (point); + const double value = (savedValue.isDouble() || savedValue.isInt() || savedValue.isInt64()) + ? (double) savedValue : 0.0; + data.push_back (std::isfinite (value) ? (float) juce::jlimit (-1.0, 1.0, value) : 0.0f); + } engine.setLfoShapeData (index, data, steps); + restored[(size_t) index] = true; } } diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h index 9176ea5..4dd1a84 100644 --- a/Source/PluginProcessor.h +++ b/Source/PluginProcessor.h @@ -1,16 +1,16 @@ #pragma once #include +#include #include "Params.h" #include "Engine.h" -#include "RAVEButton.h" namespace serum { // =========================================================================== // SerumAlt — a wavetable synthesiser (VST3 / AU). Hosts the APVTS, the audio -// engine, preset management and the RAVE controller. +// engine, preset management and the RAVE toggle. // =========================================================================== class SerumAltAudioProcessor : public juce::AudioProcessor { @@ -52,15 +52,14 @@ public: void loadFactoryPreset (int index); int getNumFactoryPresets() const; - // Public DSP state (read/write from the GUI thread). + // Public control state (hold the engine control lock for matrix/macros/LFOs). juce::AudioProcessorValueTreeState parameters; Engine engine; - RaveController rave; static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout(); private: - int currentProgram = 0; + std::atomic currentProgram { 0 }; void restoreLfoShapesFromState (const juce::ValueTree& state); void saveLfoShapesToState (juce::ValueTree& state) const; diff --git a/Source/RAVEButton.cpp b/Source/RAVEButton.cpp index a40dd5b..8d50bd6 100644 --- a/Source/RAVEButton.cpp +++ b/Source/RAVEButton.cpp @@ -1,90 +1,37 @@ #include "RAVEButton.h" +#include "SynthVoice.h" +#include "FXProcessor.h" namespace serum { -void RaveController::resetSnapshot() +void RaveController::apply (RenderContext& context, FxSlotParams* slots, int numSlots) noexcept { - snapshot.clear(); - enabled = false; -} + // Boost the static rendering parameters. + context.oscA.unison = juce::jlimit (8, kMaxUnison, context.oscA.unison); + context.oscB.unison = juce::jlimit (8, kMaxUnison, context.oscB.unison); + context.oscA.spread = 1.0f; + context.oscB.spread = 1.0f; + context.oscA.detune = 1.0f; + context.oscB.detune = 0.7f; -void RaveController::snapshotParam (const juce::String& id, juce::AudioProcessorValueTreeState& apvts) -{ - if (auto* p = apvts.getParameter (id)) - snapshot.emplace_back (id, p->getValue()); -} + // Apply drive boosts. + context.filters.f1Drive = 0.6f; + context.filters.f2Drive = 0.6f; -void RaveController::setParam (const juce::String& id, float value, juce::AudioProcessorValueTreeState& apvts) -{ - if (auto* p = apvts.getParameter (id)) - p->setValueNotifyingHost (juce::jlimit (0.0f, 1.0f, value)); -} - -void RaveController::setEnabled (bool shouldEnable, juce::AudioProcessorValueTreeState& apvts) -{ - if (shouldEnable == enabled) + // Boost FX-specific params (Hyper intensity / Reverb mix). + if (slots == nullptr) return; - - if (shouldEnable) + for (int i = 0; i < juce::jlimit (0, kNumFxSlots, numSlots); ++i) { - snapshot.clear(); - - // Snapshot the static "boost" parameters. - const char* staticParams[] = + auto& slot = slots[i]; + if (slot.type == (int) FxType::Hyper) + slot.p[0] = 1.0f; // OTT intensity 100% + else if (slot.type == (int) FxType::Reverb) { - ids::oscAUnison, ids::oscBUnison, - ids::oscASpread, ids::oscBSpread, - ids::oscADetune, ids::oscBDetune, - ids::f1Drive, ids::f2Drive - }; - for (auto id : staticParams) - snapshotParam (id, apvts); - - // Snapshot + boost FX-specific params (Hyper intensity / Reverb mix). - const char* fxMixIds[kNumFxSlots] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix, - ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix }; - const char* fxP1Ids[kNumFxSlots] = { ids::fx1P1, ids::fx2P1, ids::fx3P1, ids::fx4P1, - ids::fx5P1, ids::fx6P1, ids::fx7P1, ids::fx8P1 }; - const char* fxTypeIds[kNumFxSlots] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type, - ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type }; - - for (int i = 0; i < kNumFxSlots; ++i) - { - const auto* typeParam = apvts.getParameter (fxTypeIds[i]); - const int type = typeParam ? (int) (typeParam->getValue() * (int) FxType::Count) : 0; - - if (type == (int) FxType::Hyper) - { - snapshotParam (fxP1Ids[i], apvts); - setParam (fxP1Ids[i], 1.0f, apvts); // OTT intensity 100% - } - else if (type == (int) FxType::Reverb) - { - snapshotParam (fxMixIds[i], apvts); - const float current = apvts.getParameter (fxMixIds[i])->getValue(); - setParam (fxMixIds[i], current + 0.4f, apvts); // reverb send +6dB-ish - } + const float mix = std::isfinite (slot.mix) ? slot.mix : 0.0f; + slot.mix = juce::jlimit (0.0f, 1.0f, mix + 0.4f); // reverb send +6dB-ish } - - // Apply boosts. - setParam (ids::oscAUnison, 8.0f / 16.0f, apvts); - setParam (ids::oscBUnison, 8.0f / 16.0f, apvts); - setParam (ids::oscASpread, 1.0f, apvts); - setParam (ids::oscBSpread, 1.0f, apvts); - setParam (ids::oscADetune, 1.0f, apvts); - setParam (ids::oscBDetune, 0.7f, apvts); - setParam (ids::f1Drive, 0.6f, apvts); - setParam (ids::f2Drive, 0.6f, apvts); - - enabled = true; - } - else - { - for (const auto& entry : snapshot) - setParam (entry.first, entry.second, apvts); - snapshot.clear(); - enabled = false; } } diff --git a/Source/RAVEButton.h b/Source/RAVEButton.h index d8f1fe5..bb937fd 100644 --- a/Source/RAVEButton.h +++ b/Source/RAVEButton.h @@ -1,29 +1,22 @@ #pragma once #include -#include "Params.h" namespace serum { +struct RenderContext; +struct FxSlotParams; + // =========================================================================== -// RAVE — one-shot "make it huge" control. Toggling on snapshots the current -// values of the affected parameters and pushes unison, width, drive, OTT and -// reverb to their boosted settings; toggling off restores the snapshot. +// RAVE — non-destructive "make it huge" control. While enabled, rendering +// boosts unison, width, drive, OTT and reverb in the current block's snapshots; +// the underlying parameters remain unchanged when toggling on or off. // =========================================================================== class RaveController { public: - void setEnabled (bool enabled, juce::AudioProcessorValueTreeState& apvts); - bool isEnabled() const noexcept { return enabled; } - void resetSnapshot(); - -private: - bool enabled = false; - std::vector> snapshot; - - void snapshotParam (const juce::String& id, juce::AudioProcessorValueTreeState& apvts); - void setParam (const juce::String& id, float value, juce::AudioProcessorValueTreeState& apvts); + static void apply (RenderContext& context, FxSlotParams* slots, int numSlots) noexcept; }; } // namespace serum