diff --git a/.vscode/launch.json b/.vscode/launch.json index d0f9e5e..e81522f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,11 +2,24 @@ "version": "0.2.0", "configurations": [ { - "name": "(gdb) Launch - Linux", + "name": "Launch Reaper Linux", + "type": "cppdbg", + "request": "launch", + "program": "/usr/local/bin/reaper", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [], + "externalConsole": false, + "MIMode": "gdb", + "miDebuggerPath": "/usr/bin/gdb", + "preLaunchTask": "ProPhat CMake Configure and Debug Build" + }, + { + "name": "Launch Prophat Linux", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/Builds/ProPhat_artefacts/Debug/Standalone/ProPhat", - // "program": "/usr/local/bin/reaper", "args": [], "stopAtEntry": false, "cwd": "${workspaceFolder}", @@ -17,7 +30,7 @@ "preLaunchTask": "ProPhat CMake Configure and Debug Build" }, { - "name": "(lldb) Launch", + "name": "Launch Prophat Mac", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/Builds/ProPhat_artefacts/Debug/Standalone/ProPhat.app", @@ -29,7 +42,7 @@ "MIMode": "lldb" }, { - "name": "(gdb) Launch - Windows", + "name": "Launch Prophat Windows", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}\\Builds\\ProPhat_artefacts\\Debug\\Standalone\\ProPhat.exe", @@ -42,7 +55,7 @@ "miDebuggerPath": "C:\\path\\to\\gdb.exe" }, { - "name": "Debug Catch2 Tests", + "name": "Launch Catch2 Tests Mac", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/Builds/Tests", @@ -50,7 +63,19 @@ "cwd": "${workspaceFolder}/Builds", "stopAtEntry": false, "environment": [], - "MIMode": "lldb", // use "gdb" on Linux if preferred + "MIMode": "lldb", + "preLaunchTask": "Build and Debug Tests" + }, + { + "name": "Launch Catch2 Tests Mac", + "type": "cppdbg", + "request": "launch", + "program": "${workspaceFolder}/Builds/Tests", + "args": [], + "cwd": "${workspaceFolder}/Builds", + "stopAtEntry": false, + "environment": [], + "MIMode": "gdb", "preLaunchTask": "Build and Debug Tests" } ] diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b9dfbee..5a5dfba 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -8,12 +8,12 @@ "args": [ "-B", "Builds", - "-G", - "Ninja", - "-DCMAKE_BUILD_TYPE=Debug", - "-DCMAKE_C_COMPILER_LAUNCHER=sccache", - "-DCMAKE_CXX_COMPILER_LAUNCHER=sccache", - "-DFORMATS=Standalone", //change this to allow for other builds, e.g., -DFORMATS="Standalone;AU;VST3;AUv3" + // "-G", + // "Ninja", + // "-DCMAKE_BUILD_TYPE=Debug", + // "-DCMAKE_C_COMPILER_LAUNCHER=sccache", + // "-DCMAKE_CXX_COMPILER_LAUNCHER=sccache", + "-DFORMATS=VST3", //change this to allow for other builds, e.g., -DFORMATS="Standalone;AU;VST3;AUv3" "." ], "group": { @@ -67,7 +67,7 @@ "command": "bash", "args": [ "-c", - "cmake --build Builds -j8 --config Release" + "cmake --build Builds -j`nproc` --config Release" ], "dependsOn": [ "CMake: Configure (Release)" diff --git a/JUCE b/JUCE index 2efd3e0..7617f6b 160000 --- a/JUCE +++ b/JUCE @@ -1 +1 @@ -Subproject commit 2efd3e066102b7f8d4928f8095fddc182b982da8 +Subproject commit 7617f6b480cacd69248fbbb789e817294b64aad4 diff --git a/modules/farbot b/modules/farbot index 5b442a1..b36f989 160000 --- a/modules/farbot +++ b/modules/farbot @@ -1 +1 @@ -Subproject commit 5b442a168282b279605eab5773c4fe9e541ac04d +Subproject commit b36f9899d262e856390038a2b8c2d881540af3a9 diff --git a/source/DSP/GainedOscillator.h b/source/DSP/GainedOscillator.h index 589ba42..8acff17 100644 --- a/source/DSP/GainedOscillator.h +++ b/source/DSP/GainedOscillator.h @@ -31,6 +31,31 @@ class GainedOscillator GainedOscillator () : distribution ((T) -1, (T) 1) { + //TODO: I should compare these waves on the scope with the waves from the prophet + oscs[OscShape::none].initialise ([] (T /*x*/) { return T (0); }); + oscs[OscShape::saw].initialise ([] (T x) { return juce::jmap (x, T (-juce::MathConstants::pi), T (juce::MathConstants::pi), T (-1), T (1)); }, 2); + + oscs[OscShape::sawTri].initialise ([] (T x) + { + T y = juce::jmap (x, T (-juce::MathConstants::pi), T (juce::MathConstants::pi), T (-1), T (1)) / 2; + + if (x < 0) + return y += juce::jmap (x, T (-juce::MathConstants::pi), T (0), T (-1), T (1)) / 2; + else + return y += juce::jmap (x, T (0), T (juce::MathConstants::pi), T (1), T (-1)) / 2; + }, 128); + + oscs[OscShape::triangle].initialise ([] (T x) + { + if (x < 0) + return juce::jmap (x, T (-juce::MathConstants::pi), T (0), T (-1), T (1)); + else + return juce::jmap (x, T (0), T (juce::MathConstants::pi), T (1), T (-1)); + }, 128); + + oscs[OscShape::pulse].initialise ([] (T x) { if (x < 0) return T (-1); else return T (1); }, 16); + oscs[OscShape::noise].initialise ([this] (T /*x*/) { return distribution (generator); }); + setOscShape (OscShape::saw); setGain (Constants::defaultOscLevel); } @@ -39,11 +64,44 @@ class GainedOscillator { jassert (newValue > 0); - auto& osc = processorChain.template get (); - osc.setFrequency (newValue, force); + curOsc.load()->setFrequency (newValue, force); } - void setOscShape (OscShape::Values newShape) { nextOsc.store (newShape); } + void setOscShape (OscShape::Values newShape) + { + //TODO: AFAICT we never get in here so probably useless? + // Early-out if the requested shape is already selected. + auto* currentPtr = curOsc.load(); + auto* requestedPtr = &oscs[static_cast (newShape)]; + if (currentPtr == requestedPtr) + return; + + //this is to make sure we preserve the same gain after we re-init, right? + bool wasActive = isActive; + isActive = true; + + if (newShape == OscShape::none) + isActive = false; + + switch (newShape) + { + case OscShape::none: curOsc.store (&oscs[OscShape::none]); break; + case OscShape::saw: curOsc.store (&oscs[OscShape::saw]); break; + case OscShape::sawTri: curOsc.store (&oscs[OscShape::sawTri]); break; + case OscShape::triangle: curOsc.store (&oscs[OscShape::triangle]); break; + case OscShape::pulse: curOsc.store (&oscs[OscShape::pulse]); break; + case OscShape::noise: curOsc.store (&oscs[OscShape::noise]); break; + default: jassertfalse; + } + + if (wasActive != isActive) + { + if (isActive) + setGain (lastActiveGain); + else + setGain (0); + } + } /** * @brief Sets the gain for the oscillator in the processorChain. @@ -58,142 +116,44 @@ class GainedOscillator else lastActiveGain = newGain; - auto& gain = processorChain.template get (); gain.setGainLinear (newGain); } T getGain () { return lastActiveGain; } - void reset () noexcept { processorChain.reset (); } + void reset () noexcept + { + curOsc.load ()->reset(); + gain.reset(); + } template void process (const ProcessContext& context) noexcept { - updateOscillators(); - - processorChain.process (context); + curOsc.load ()->process (context); + gain.process (context); } void prepare (const juce::dsp::ProcessSpec& spec) { - processorChain.prepare (spec); + for (auto& osc : oscs) + osc.prepare (spec); + + gain.prepare (spec); } private: - enum - { - oscIndex, - gainIndex - }; - std::atomic currentOsc { OscShape::none }, nextOsc { OscShape::saw }; - - void updateOscillators(); + std::array, OscShape::actualTotal> oscs; + std::atomic*> curOsc { nullptr }; bool isActive = true; T lastActiveGain {}; - - juce::dsp::ProcessorChain, juce::dsp::Gain> processorChain; + juce::dsp::Gain gain; std::uniform_real_distribution distribution; std::default_random_engine generator; }; //==================================================================================================== - -template -void GainedOscillator::updateOscillators() -{ - //compare the current osc type with the (buffered next osc type). Get outta here if they the same - const auto nextOscBuf { nextOsc.load() }; - if (currentOsc == nextOscBuf) - return; - - //this is to make sure we preserve the same gain after we re-init, right? - bool wasActive = isActive; - isActive = true; - - auto& osc = processorChain.template get(); - switch (nextOscBuf) - { - case OscShape::none: - osc.initialise ([&] (T /*x*/) { return T (0); }); - isActive = false; - break; - - case OscShape::saw: - { - osc.initialise ([](T x) - { - //this is a sawtooth wave; as x goes from -pi to pi, y goes from -1 to 1 - return juce::jmap (x, T (-juce::MathConstants::pi), T (juce::MathConstants::pi), T (-1), T (1)); - }, 2); - } - break; - - case OscShape::sawTri: - { - osc.initialise ([](T x) - { - T y = juce::jmap (x, T (-juce::MathConstants::pi), T (juce::MathConstants::pi), T (-1), T (1)) / 2; - - if (x < 0) - return y += juce::jmap (x, T (-juce::MathConstants::pi), T (0), T (-1), T (1)) / 2; - else - return y += juce::jmap (x, T (0), T (juce::MathConstants::pi), T (1), T (-1)) / 2; - - }, 128); - } - break; - - case OscShape::triangle: - { - osc.initialise ([](T x) - { - if (x < 0) - return juce::jmap (x, T (-juce::MathConstants::pi), T (0), T (-1), T (1)); - else - return juce::jmap (x, T (0), T (juce::MathConstants::pi), T (1), T (-1)); - - }, 128); - } - break; - - case OscShape::pulse: - { - osc.initialise ([](T x) - { - if (x < 0) - return T (-1); - else - return T (1); - }, 128); - } - break; - - case OscShape::noise: - { - osc.initialise ([&](T /*x*/) - { - return distribution (generator); - }); - } - break; - - case OscShape::totalSelectable: - default: - jassertfalse; - break; - } - - if (wasActive != isActive) - { - if (isActive) - setGain (lastActiveGain); - else - setGain (0); - } - - currentOsc.store (nextOscBuf); -} diff --git a/source/DSP/LockFreeSynthesiser.cpp b/source/DSP/LockFreeSynthesiser.cpp new file mode 100644 index 0000000..17e09dd --- /dev/null +++ b/source/DSP/LockFreeSynthesiser.cpp @@ -0,0 +1,521 @@ +/* +============================================================================== + + ProPhat is a virtual synthesizer inspired by the Prophet REV2. + Copyright (C) 2025 Vincent Berthiaume + + 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 3 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. If not, see . + + ============================================================================== +*/ + +#include "LockFreeSynthesiser.h" +#include "juce_events/juce_events.h" + +bool LockFreeSynthesiserVoice::isPlayingChannel (const int midiChannel) const +{ + return currentPlayingMidiChannel == midiChannel; +} + +void LockFreeSynthesiserVoice::setCurrentPlaybackSampleRate (const double newRate) +{ + currentSampleRate = newRate; +} + +bool LockFreeSynthesiserVoice::isVoiceActive() const +{ + return getCurrentlyPlayingNote() >= 0; +} + +void LockFreeSynthesiserVoice::clearCurrentNote() +{ + currentlyPlayingNote = -1; + currentlyPlayingSound = nullptr; + currentPlayingMidiChannel = 0; +} + +bool LockFreeSynthesiserVoice::wasStartedBefore (const LockFreeSynthesiserVoice& other) const noexcept +{ + return noteOnTime < other.noteOnTime; +} + +void LockFreeSynthesiserVoice::renderNextBlock (juce::AudioBuffer& outputBuffer, int startSample, int numSamples) +{ + juce::AudioBuffer subBuffer (outputBuffer.getArrayOfWritePointers(), outputBuffer.getNumChannels(), startSample, numSamples); + + tempBuffer.makeCopyOf (subBuffer, true); + renderNextBlock (tempBuffer, 0, numSamples); + subBuffer.makeCopyOf (tempBuffer, true); +} + +//====================================================== + +LockFreeSynthesiser::LockFreeSynthesiser() +{ + for (int i = 0; i < juce::numElementsInArray (lastPitchWheelValues); ++i) + lastPitchWheelValues[i] = 0x2000; +} + +LockFreeSynthesiserVoice* LockFreeSynthesiser::addVoice (LockFreeSynthesiserVoice* const newVoice) +{ + JUCE_ASSERT_MESSAGE_THREAD; + + newVoice->setCurrentPlaybackSampleRate (sampleRate); + + auto* voice = voices.add (newVoice); + + usableVoicesToStealArray.ensureStorageAllocated (voices.size() + 1); + + return voice; +} + +juce::SynthesiserSound* LockFreeSynthesiser::addSound (const juce::SynthesiserSound::Ptr& newSound) +{ + JUCE_ASSERT_MESSAGE_THREAD; + + return sounds.add (newSound); +} + +void LockFreeSynthesiser::setNoteStealingEnabled (const bool shouldSteal) +{ + shouldStealNotes = shouldSteal; +} + +void LockFreeSynthesiser::setMinimumRenderingSubdivisionSize (int numSamples, bool shouldBeStrict) noexcept +{ + jassert (numSamples > 0); // it wouldn't make much sense for this to be less than 1 + minimumSubBlockSize = numSamples; + subBlockSubdivisionIsStrict = shouldBeStrict; +} + +//============================================================================== +void LockFreeSynthesiser::setCurrentPlaybackSampleRate (const double newRate) +{ + if (! juce::approximatelyEqual (sampleRate, newRate)) + { + allNotesOff (0, false); + sampleRate = newRate; + + for (auto* voice : voices) + voice->setCurrentPlaybackSampleRate (newRate); + } +} + +template +void LockFreeSynthesiser::processNextBlock (juce::AudioBuffer& outputAudio, + const juce::MidiBuffer& midiData, + int startSample, + int numSamples) +{ + // must set the sample rate before using this! + jassert (! juce::exactlyEqual (sampleRate, 0.0)); + const int targetChannels = outputAudio.getNumChannels(); + + auto midiIterator = midiData.findNextSamplePosition (startSample); + + bool firstEvent = true; + + for (; numSamples > 0; ++midiIterator) + { + if (midiIterator == midiData.cend()) + { + if (targetChannels > 0) + renderVoices (outputAudio, startSample, numSamples); + + return; + } + + const auto metadata = *midiIterator; + const int samplesToNextMidiMessage = metadata.samplePosition - startSample; + + if (samplesToNextMidiMessage >= numSamples) + { + if (targetChannels > 0) + renderVoices (outputAudio, startSample, numSamples); + + handleMidiEvent (metadata.getMessage()); + break; + } + + if (samplesToNextMidiMessage < ((firstEvent && ! subBlockSubdivisionIsStrict) ? 1 : minimumSubBlockSize)) + { + handleMidiEvent (metadata.getMessage()); + continue; + } + + firstEvent = false; + + if (targetChannels > 0) + renderVoices (outputAudio, startSample, samplesToNextMidiMessage); + + handleMidiEvent (metadata.getMessage()); + startSample += samplesToNextMidiMessage; + numSamples -= samplesToNextMidiMessage; + } + + std::for_each (midiIterator, + midiData.cend(), + [&] (const juce::MidiMessageMetadata& meta) + { handleMidiEvent (meta.getMessage()); }); +} + +// explicit template instantiation +template void LockFreeSynthesiser::processNextBlock (juce::AudioBuffer&, const juce::MidiBuffer&, int, int); +template void LockFreeSynthesiser::processNextBlock (juce::AudioBuffer&, const juce::MidiBuffer&, int, int); + +void LockFreeSynthesiser::renderNextBlock (juce::AudioBuffer& outputAudio, const juce::MidiBuffer& inputMidi, int startSample, int numSamples) +{ + processNextBlock (outputAudio, inputMidi, startSample, numSamples); +} + +void LockFreeSynthesiser::renderNextBlock (juce::AudioBuffer& outputAudio, const juce::MidiBuffer& inputMidi, int startSample, int numSamples) +{ + processNextBlock (outputAudio, inputMidi, startSample, numSamples); +} + +void LockFreeSynthesiser::renderVoices (juce::AudioBuffer& buffer, int startSample, int numSamples) +{ + for (auto* voice : voices) + voice->renderNextBlock (buffer, startSample, numSamples); +} + +void LockFreeSynthesiser::renderVoices (juce::AudioBuffer& buffer, int startSample, int numSamples) +{ + for (auto* voice : voices) + voice->renderNextBlock (buffer, startSample, numSamples); +} + +void LockFreeSynthesiser::handleMidiEvent (const juce::MidiMessage& m) +{ + const int channel = m.getChannel(); + + if (m.isNoteOn()) + { + noteOn (channel, m.getNoteNumber(), m.getFloatVelocity()); + } + else if (m.isNoteOff()) + { + noteOff (channel, m.getNoteNumber(), m.getFloatVelocity(), true); + } + else if (m.isAllNotesOff() || m.isAllSoundOff()) + { + allNotesOff (channel, true); + } + else if (m.isPitchWheel()) + { + const int wheelPos = m.getPitchWheelValue(); + lastPitchWheelValues[channel - 1] = wheelPos; + handlePitchWheel (channel, wheelPos); + } + else if (m.isAftertouch()) + { + handleAftertouch (channel, m.getNoteNumber(), m.getAfterTouchValue()); + } + else if (m.isChannelPressure()) + { + handleChannelPressure (channel, m.getChannelPressureValue()); + } + else if (m.isController()) + { + handleController (channel, m.getControllerNumber(), m.getControllerValue()); + } + else if (m.isProgramChange()) + { + handleProgramChange (channel, m.getProgramChangeNumber()); + } +} + +//============================================================================== +static void stopVoice (LockFreeSynthesiserVoice* voice, float velocity, const bool allowTailOff) +{ + if (voice == nullptr) + { + jassertfalse; + return; + } + + voice->stopNote (velocity, allowTailOff); + + // the subclass MUST call clearCurrentNote() if it's not tailing off! RTFM for stopNote()! + jassert (allowTailOff || (voice->getCurrentlyPlayingNote() < 0 && voice->getCurrentlyPlayingSound() == nullptr)); +} + +void LockFreeSynthesiser::noteOn (const int midiChannel, const int midiNoteNumber, const float velocity) +{ + for (auto* sound : sounds) + { + if (sound->appliesToNote (midiNoteNumber) && sound->appliesToChannel (midiChannel)) + { + // If hitting a note that's still ringing, stop it first (it could be + // still playing because of the sustain or sostenuto pedal). + for (auto* voice : voices) + if (voice->getCurrentlyPlayingNote() == midiNoteNumber && voice->isPlayingChannel (midiChannel)) + stopVoice (voice, 1.0f, true); + + startVoice (findFreeVoice (sound, midiChannel, midiNoteNumber, shouldStealNotes), + sound, + midiChannel, + midiNoteNumber, + velocity); + } + } +} + +void LockFreeSynthesiser::startVoice (LockFreeSynthesiserVoice* const voice, + juce::SynthesiserSound* const sound, + const int midiChannel, + const int midiNoteNumber, + const float velocity) +{ + if (voice != nullptr && sound != nullptr) + { + if (voice->currentlyPlayingSound != nullptr) + voice->stopNote (0.0f, false); + + voice->currentlyPlayingNote = midiNoteNumber; + voice->currentPlayingMidiChannel = midiChannel; + voice->noteOnTime = ++lastNoteOnCounter; + voice->currentlyPlayingSound = sound; + voice->setKeyDown (true); + voice->setSostenutoPedalDown (false); + voice->setSustainPedalDown (sustainPedalsDown[midiChannel]); + + voice->startNote (midiNoteNumber, velocity, sound, lastPitchWheelValues[midiChannel - 1]); + } +} + +void LockFreeSynthesiser::noteOff (const int midiChannel, const int midiNoteNumber, const float velocity, const bool allowTailOff) +{ + for (auto* voice : voices) + { + if (voice->getCurrentlyPlayingNote() == midiNoteNumber && voice->isPlayingChannel (midiChannel)) + { + if (const auto sound = voice->getCurrentlyPlayingSound()) + { + if (sound->appliesToNote (midiNoteNumber) && sound->appliesToChannel (midiChannel)) + { + jassert (! voice->keyIsDown || voice->isSustainPedalDown() == sustainPedalsDown[midiChannel]); + + voice->setKeyDown (false); + + if (! (voice->isSustainPedalDown() || voice->isSostenutoPedalDown())) + stopVoice (voice, velocity, allowTailOff); + } + } + } + } +} + +void LockFreeSynthesiser::allNotesOff (const int midiChannel, const bool allowTailOff) +{ + for (auto* voice : voices) + if (midiChannel <= 0 || voice->isPlayingChannel (midiChannel)) + voice->stopNote (1.0f, allowTailOff); + + sustainPedalsDown.clear(); +} + +void LockFreeSynthesiser::handlePitchWheel (const int midiChannel, const int wheelValue) +{ + for (auto* voice : voices) + if (midiChannel <= 0 || voice->isPlayingChannel (midiChannel)) + voice->pitchWheelMoved (wheelValue); +} + +void LockFreeSynthesiser::handleController (const int midiChannel, + const int controllerNumber, + const int controllerValue) +{ + switch (controllerNumber) + { + case 0x40: handleSustainPedal (midiChannel, controllerValue >= 64); break; + case 0x42: handleSostenutoPedal (midiChannel, controllerValue >= 64); break; + case 0x43: handleSoftPedal (midiChannel, controllerValue >= 64); break; + default: break; + } + + for (auto* voice : voices) + if (midiChannel <= 0 || voice->isPlayingChannel (midiChannel)) + voice->controllerMoved (controllerNumber, controllerValue); +} + +void LockFreeSynthesiser::handleAftertouch (int midiChannel, int midiNoteNumber, int aftertouchValue) +{ + for (auto* voice : voices) + if (voice->getCurrentlyPlayingNote() == midiNoteNumber + && (midiChannel <= 0 || voice->isPlayingChannel (midiChannel))) + voice->aftertouchChanged (aftertouchValue); +} + +void LockFreeSynthesiser::handleChannelPressure (int midiChannel, int channelPressureValue) +{ + for (auto* voice : voices) + if (midiChannel <= 0 || voice->isPlayingChannel (midiChannel)) + voice->channelPressureChanged (channelPressureValue); +} + +void LockFreeSynthesiser::handleSustainPedal (int midiChannel, bool isDown) +{ + jassert (midiChannel > 0 && midiChannel <= 16); + + if (isDown) + { + sustainPedalsDown.setBit (midiChannel); + + for (auto* voice : voices) + if (voice->isPlayingChannel (midiChannel) && voice->isKeyDown()) + voice->setSustainPedalDown (true); + } + else + { + for (auto* voice : voices) + { + if (voice->isPlayingChannel (midiChannel)) + { + voice->setSustainPedalDown (false); + + if (! (voice->isKeyDown() || voice->isSostenutoPedalDown())) + stopVoice (voice, 1.0f, true); + } + } + + sustainPedalsDown.clearBit (midiChannel); + } +} + +void LockFreeSynthesiser::handleSostenutoPedal (int midiChannel, bool isDown) +{ + jassert (midiChannel > 0 && midiChannel <= 16); + + for (auto* voice : voices) + { + if (voice->isPlayingChannel (midiChannel)) + { + if (isDown) + voice->setSostenutoPedalDown (true); + else if (voice->isSostenutoPedalDown()) + stopVoice (voice, 1.0f, true); + } + } +} + +void LockFreeSynthesiser::handleSoftPedal ([[maybe_unused]] int midiChannel, bool /*isDown*/) +{ + jassert (midiChannel > 0 && midiChannel <= 16); +} + +void LockFreeSynthesiser::handleProgramChange ([[maybe_unused]] int midiChannel, + [[maybe_unused]] int programNumber) +{ + jassert (midiChannel > 0 && midiChannel <= 16); +} + +//============================================================================== +LockFreeSynthesiserVoice* LockFreeSynthesiser::findFreeVoice (juce::SynthesiserSound* soundToPlay, + int midiChannel, + int midiNoteNumber, + const bool stealIfNoneAvailable) const +{ + for (auto* voice : voices) + if ((! voice->isVoiceActive()) && voice->canPlaySound (soundToPlay)) + return voice; + + if (stealIfNoneAvailable) + return findVoiceToSteal (soundToPlay, midiChannel, midiNoteNumber); + + return nullptr; +} + +LockFreeSynthesiserVoice* LockFreeSynthesiser::findVoiceToSteal (juce::SynthesiserSound* soundToPlay, + int /*midiChannel*/, + int midiNoteNumber) const +{ + // This voice-stealing algorithm applies the following heuristics: + // - Re-use the oldest notes first + // - Protect the lowest & topmost notes, even if sustained, but not if they've been released. + + // apparently you are trying to render audio without having any voices... + jassert (! voices.isEmpty()); + + // These are the voices we want to protect (ie: only steal if unavoidable) + LockFreeSynthesiserVoice* low = nullptr; // Lowest sounding note, might be sustained, but NOT in release phase + LockFreeSynthesiserVoice* top = nullptr; // Highest sounding note, might be sustained, but NOT in release phase + + // this is a list of voices we can steal, sorted by how long they've been running + usableVoicesToStealArray.clear(); + + for (auto* voice : voices) + { + if (voice->canPlaySound (soundToPlay)) + { + jassert (voice->isVoiceActive()); // We wouldn't be here otherwise + + usableVoicesToStealArray.add (voice); + + // NB: Using a functor rather than a lambda here due to scare-stories about + // compilers generating code containing heap allocations. + struct Sorter + { + bool operator() (const LockFreeSynthesiserVoice* a, const LockFreeSynthesiserVoice* b) const noexcept { return a->wasStartedBefore (*b); } + }; + + //TODO: this used to be `std::sort (usableVoicesToStealArray.begin(), usableVoicesToStealArray.end(), Sorter());`, i should probably check that this new code works the same + std::ranges::sort (usableVoicesToStealArray, Sorter()); + + if (! voice->isPlayingButReleased()) // Don't protect released notes + { + const auto note = voice->getCurrentlyPlayingNote(); + + if (low == nullptr || note < low->getCurrentlyPlayingNote()) + low = voice; + + if (top == nullptr || note > top->getCurrentlyPlayingNote()) + top = voice; + } + } + } + + // Eliminate pathological cases (ie: only 1 note playing): we always give precedence to the lowest note(s) + if (top == low) + top = nullptr; + + // The oldest note that's playing with the target pitch is ideal. + for (auto* voice : usableVoicesToStealArray) + if (voice->getCurrentlyPlayingNote() == midiNoteNumber) + return voice; + + // Oldest voice that has been released (no finger on it and not held by sustain pedal) + for (auto* voice : usableVoicesToStealArray) + if (voice != low && voice != top && voice->isPlayingButReleased()) + return voice; + + // Oldest voice that doesn't have a finger on it: + for (auto* voice : usableVoicesToStealArray) + if (voice != low && voice != top && ! voice->isKeyDown()) + return voice; + + // Oldest voice that isn't protected + for (auto* voice : usableVoicesToStealArray) + if (voice != low && voice != top) + return voice; + + // We've only got "protected" voices now: lowest note takes priority + jassert (low != nullptr); + + // Duophonic synth: give priority to the bass note: + if (top != nullptr) + return top; + + return low; +} \ No newline at end of file diff --git a/source/DSP/LockFreeSynthesiser.h b/source/DSP/LockFreeSynthesiser.h new file mode 100644 index 0000000..bda123e --- /dev/null +++ b/source/DSP/LockFreeSynthesiser.h @@ -0,0 +1,529 @@ +/* +============================================================================== + + ProPhat is a virtual synthesizer inspired by the Prophet REV2. + Copyright (C) 2025 Vincent Berthiaume + + 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 3 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. If not, see . + + ============================================================================== +*/ + +#pragma once + +#include "juce_audio_basics/juce_audio_basics.h" + +/** + This is a lock-free copy of juce::SynthesiserVoice. + + Represents a voice that a Synthesiser can use to play a SynthesiserSound. + + A voice plays a single sound at a time, and a synthesiser holds an array of + voices so that it can play polyphonically. + + @see Synthesiser, SynthesiserSound + + @tags{Audio} +*/ +class LockFreeSynthesiserVoice +{ + public: + //============================================================================== + /** Creates a voice. */ + LockFreeSynthesiserVoice() = default; + + /** Destructor. */ + virtual ~LockFreeSynthesiserVoice() = default; + + //============================================================================== + /** Returns the midi note that this voice is currently playing. + Returns a value less than 0 if no note is playing. + */ + [[nodiscard]] int getCurrentlyPlayingNote() const noexcept { return currentlyPlayingNote; } + + /** Returns the sound that this voice is currently playing. + Returns nullptr if it's not playing. + */ + [[nodiscard]] juce::SynthesiserSound::Ptr getCurrentlyPlayingSound() const noexcept { return currentlyPlayingSound; } + + /** Must return true if this voice object is capable of playing the given sound. + + If there are different classes of sound, and different classes of voice, a voice can + choose which ones it wants to take on. + + A typical implementation of this method may just return true if there's only one type + of voice and sound, or it might check the type of the sound object passed-in and + see if it's one that it understands. + */ + virtual bool canPlaySound (juce::SynthesiserSound*) = 0; + + /** Called to start a new note. + This will be called during the rendering callback, so must be fast and thread-safe. + */ + virtual void startNote (int midiNoteNumber, + float velocity, + juce::SynthesiserSound* sound, + int currentPitchWheelPosition) + = 0; + + /** Called to stop a note. + + This will be called during the rendering callback, so must be fast and thread-safe. + + The velocity indicates how quickly the note was released - 0 is slowly, 1 is quickly. + + If allowTailOff is false or the voice doesn't want to tail-off, then it must stop all + sound immediately, and must call clearCurrentNote() to reset the state of this voice + and allow the synth to reassign it another sound. + + If allowTailOff is true and the voice decides to do a tail-off, then it's allowed to + begin fading out its sound, and it can stop playing until it's finished. As soon as it + finishes playing (during the rendering callback), it must make sure that it calls + clearCurrentNote(). + */ + virtual void stopNote (float velocity, bool allowTailOff) = 0; + + /** Returns true if this voice is currently busy playing a sound. + By default, this just checks the getCurrentlyPlayingNote() value, but can + be overridden for more advanced checking. + */ + [[nodiscard]] virtual bool isVoiceActive() const; + + /** Called to let the voice know that the pitch wheel has been moved. + This will be called during the rendering callback, so must be fast and thread-safe. + */ + virtual void pitchWheelMoved (int newPitchWheelValue) = 0; + + /** Called to let the voice know that a midi controller has been moved. + This will be called during the rendering callback, so must be fast and thread-safe. + */ + virtual void controllerMoved (int controllerNumber, int newControllerValue) = 0; + + /** Called to let the voice know that the aftertouch has changed. + This will be called during the rendering callback, so must be fast and thread-safe. + */ + virtual void aftertouchChanged (int /*newAftertouchValue*/) {} + + /** Called to let the voice know that the channel pressure has changed. + This will be called during the rendering callback, so must be fast and thread-safe. + */ + virtual void channelPressureChanged (int /*newChannelPressureValue*/) {} + + //============================================================================== + /** Renders the next block of data for this voice. + + The output audio data must be added to the current contents of the buffer provided. + Only the region of the buffer between startSample and (startSample + numSamples) + should be altered by this method. + + If the voice is currently silent, it should just return without doing anything. + + If the sound that the voice is playing finishes during the course of this rendered + block, it must call clearCurrentNote(), to tell the synthesiser that it has finished. + + The size of the blocks that are rendered can change each time it is called, and may + involve rendering as little as 1 sample at a time. In between rendering callbacks, + the voice's methods will be called to tell it about note and controller events. + */ + virtual void renderNextBlock (juce::AudioBuffer& outputBuffer, + int startSample, + int numSamples) + = 0; + + /** A double-precision version of renderNextBlock() */ + virtual void renderNextBlock (juce::AudioBuffer& outputBuffer, + int startSample, + int numSamples); + + /** Changes the voice's reference sample rate. + + The rate is set so that subclasses know the output rate and can set their pitch + accordingly. + + This method is called by the synth, and subclasses can access the current rate with + the currentSampleRate member. + */ + virtual void setCurrentPlaybackSampleRate (double newRate); + + /** Returns true if the voice is currently playing a sound which is mapped to the given + midi channel. + + If it's not currently playing, this will return false. + */ + virtual bool isPlayingChannel (int midiChannel) const; + + /** Returns the current target sample rate at which rendering is being done. + Subclasses may need to know this so that they can pitch things correctly. + */ + [[nodiscard]] double getSampleRate() const noexcept { return currentSampleRate; } + + /** Returns true if the key that triggered this voice is still held down. + Note that the voice may still be playing after the key was released (e.g., because the + sostenuto pedal is down). + */ + [[nodiscard]] bool isKeyDown() const noexcept { return keyIsDown; } + + /** Allows you to modify the flag indicating that the key that triggered this voice is still held down. + @see isKeyDown + */ + void setKeyDown (bool isNowDown) noexcept { keyIsDown = isNowDown; } + + /** Returns true if the sustain pedal is currently active for this voice. */ + [[nodiscard]] bool isSustainPedalDown() const noexcept { return sustainPedalDown; } + + /** Modifies the sustain pedal flag. */ + void setSustainPedalDown (bool isNowDown) noexcept { sustainPedalDown = isNowDown; } + + /** Returns true if the sostenuto pedal is currently active for this voice. */ + [[nodiscard]] bool isSostenutoPedalDown() const noexcept { return sostenutoPedalDown; } + + /** Modifies the sostenuto pedal flag. */ + void setSostenutoPedalDown (bool isNowDown) noexcept { sostenutoPedalDown = isNowDown; } + + /** Returns true if a voice is sounding in its release phase **/ + [[nodiscard]] bool isPlayingButReleased() const noexcept + { + return isVoiceActive() && ! (isKeyDown() || isSostenutoPedalDown() || isSustainPedalDown()); + } + + /** Returns true if this voice started playing its current note before the other voice did. */ + [[nodiscard]] bool wasStartedBefore (const LockFreeSynthesiserVoice& other) const noexcept; + + protected: + /** Resets the state of this voice after a sound has finished playing. + + The subclass must call this when it finishes playing a note and becomes available + to play new ones. + + It must either call it in the stopNote() method, or if the voice is tailing off, + then it should call it later during the renderNextBlock method, as soon as it + finishes its tail-off. + + It can also be called at any time during the render callback if the sound happens + to have finished, e.g. if it's playing a sample and the sample finishes. + */ + void clearCurrentNote(); + + private: + friend class LockFreeSynthesiser; + + double currentSampleRate = 44100.0; + int currentlyPlayingNote = -1, currentPlayingMidiChannel = 0; + juce::uint32 noteOnTime = 0; + juce::SynthesiserSound::Ptr currentlyPlayingSound; + bool keyIsDown = false, sustainPedalDown = false, sostenutoPedalDown = false; + + juce::AudioBuffer tempBuffer; + + JUCE_LEAK_DETECTOR (LockFreeSynthesiserVoice) +}; + +//=================================================================== +/** This is a copy of juce::Synthesiser but modified to have a fixed number of voices and sounds, and thus avoid locks in the audio thread. + @see juce::Synthesiser, LockFreeSynthesiserVoice + @tags{Audio} +*/ +class LockFreeSynthesiser +{ + public: + /** Creates a new synthesiser. + You'll need to add some sounds and voices before it'll make any sound. + */ + LockFreeSynthesiser(); + + /** Destructor. */ + virtual ~LockFreeSynthesiser() {} + + /** Returns the number of voices that have been added. */ + [[nodiscard]] int getNumVoices() const noexcept { return voices.size(); } + + /** Returns the number of sounds that have been added to the synth. */ + [[nodiscard]] int getNumSounds() const noexcept { return sounds.size(); } + + /** Returns one of the sounds. */ + [[nodiscard]] juce::SynthesiserSound::Ptr getSound (int index) const noexcept { return sounds[index]; } + + /** If set to true, then the synth will try to take over an existing voice if + it runs out and needs to play another note. + + The value of this boolean is passed into findFreeVoice(), so the result will + depend on the implementation of this method. + */ + void setNoteStealingEnabled (bool shouldStealNotes); + + /** Returns true if note-stealing is enabled. + @see setNoteStealingEnabled + */ + [[nodiscard]] bool isNoteStealingEnabled() const noexcept { return shouldStealNotes; } + + //============================================================================== + /** Triggers a note-on event. + + The default method here will find all the sounds that want to be triggered by + this note/channel. For each sound, it'll try to find a free voice, and use the + voice to start playing the sound. + + Subclasses might want to override this if they need a more complex algorithm. + + This method will be called automatically according to the midi data passed into + renderNextBlock(), but may be called explicitly too. + + The midiChannel parameter is the channel, between 1 and 16 inclusive. + */ + virtual void noteOn (int midiChannel, int midiNoteNumber, float velocity); + + /** Triggers a note-off event. + + This will turn off any voices that are playing a sound for the given note/channel. + + If allowTailOff is true, the voices will be allowed to fade out the notes gracefully + (if they can do). If this is false, the notes will all be cut off immediately. + + This method will be called automatically according to the midi data passed into + renderNextBlock(), but may be called explicitly too. + + The midiChannel parameter is the channel, between 1 and 16 inclusive. + */ + virtual void noteOff (int midiChannel, int midiNoteNumber, float velocity, bool allowTailOff); + + /** Turns off all notes. + + This will turn off any voices that are playing a sound on the given midi channel. + + If midiChannel is 0 or less, then all voices will be turned off, regardless of + which channel they're playing. Otherwise, it represents a valid midi channel, from + 1 to 16 inclusive. + + If allowTailOff is true, the voices will be allowed to fade out the notes gracefully + (if they can do). If this is false, the notes will all be cut off immediately. + + This method will be called automatically according to the midi data passed into + renderNextBlock(), but may be called explicitly too. + */ + virtual void allNotesOff (int midiChannel, bool allowTailOff); + + /** Sends a pitch-wheel message to any active voices. + + This will send a pitch-wheel message to any voices that are playing sounds on + the given midi channel. + + This method will be called automatically according to the midi data passed into + renderNextBlock(), but may be called explicitly too. + + @param midiChannel the midi channel, from 1 to 16 inclusive + @param wheelValue the wheel position, from 0 to 0x3fff, as returned by MidiMessage::getPitchWheelValue() + */ + virtual void handlePitchWheel (int midiChannel, + int wheelValue); + + /** Sends a midi controller message to any active voices. + + This will send a midi controller message to any voices that are playing sounds on + the given midi channel. + + This method will be called automatically according to the midi data passed into + renderNextBlock(), but may be called explicitly too. + + @param midiChannel the midi channel, from 1 to 16 inclusive + @param controllerNumber the midi controller type, as returned by MidiMessage::getControllerNumber() + @param controllerValue the midi controller value, between 0 and 127, as returned by MidiMessage::getControllerValue() + */ + virtual void handleController (int midiChannel, int controllerNumber, int controllerValue); + + /** Sends an aftertouch message. + + This will send an aftertouch message to any voices that are playing sounds on + the given midi channel and note number. + + This method will be called automatically according to the midi data passed into + renderNextBlock(), but may be called explicitly too. + + @param midiChannel the midi channel, from 1 to 16 inclusive + @param midiNoteNumber the midi note number, 0 to 127 + @param aftertouchValue the aftertouch value, between 0 and 127, + as returned by MidiMessage::getAftertouchValue() + */ + virtual void handleAftertouch (int midiChannel, int midiNoteNumber, int aftertouchValue); + + /** Sends a channel pressure message. + + This will send a channel pressure message to any voices that are playing sounds on + the given midi channel. + + This method will be called automatically according to the midi data passed into + renderNextBlock(), but may be called explicitly too. + + @param midiChannel the midi channel, from 1 to 16 inclusive + @param channelPressureValue the pressure value, between 0 and 127, as returned + by MidiMessage::getChannelPressureValue() + */ + virtual void handleChannelPressure (int midiChannel, int channelPressureValue); + + /** Handles a sustain pedal event. */ + virtual void handleSustainPedal (int midiChannel, bool isDown); + + /** Handles a sostenuto pedal event. */ + virtual void handleSostenutoPedal (int midiChannel, bool isDown); + + /** Can be overridden to handle soft pedal events. */ + virtual void handleSoftPedal (int midiChannel, bool isDown); + + /** Can be overridden to handle an incoming program change message. + The base class implementation of this has no effect, but you may want to make your + own synth react to program changes. + */ + virtual void handleProgramChange (int midiChannel, int programNumber); + + //============================================================================== + /** Tells the synthesiser what the sample rate is for the audio it's being used to render. + + This value is propagated to the voices so that they can use it to render the correct + pitches. + */ + virtual void setCurrentPlaybackSampleRate (double sampleRate); + + /** Creates the next block of audio output. + + This will process the next numSamples of data from all the voices, and add that output + to the audio block supplied, starting from the offset specified. Note that the + data will be added to the current contents of the buffer, so you should clear it + before calling this method if necessary. + + The midi events in the inputMidi buffer are parsed for note and controller events, + and these are used to trigger the voices. Note that the startSample offset applies + both to the audio output buffer and the midi input buffer, so any midi events + with timestamps outside the specified region will be ignored. + */ + void renderNextBlock (juce::AudioBuffer& outputAudio, + const juce::MidiBuffer& inputMidi, + int startSample, + int numSamples); + + void renderNextBlock (juce::AudioBuffer& outputAudio, + const juce::MidiBuffer& inputMidi, + int startSample, + int numSamples); + + /** Returns the current target sample rate at which rendering is being done. + Subclasses may need to know this so that they can pitch things correctly. + */ + [[nodiscard]] double getSampleRate() const noexcept { return sampleRate; } + + /** Sets a minimum limit on the size to which audio sub-blocks will be divided when rendering. + + When rendering, the audio blocks that are passed into renderNextBlock() will be split up + into smaller blocks that lie between all the incoming midi messages, and it is these smaller + sub-blocks that are rendered with multiple calls to renderVoices(). + + Obviously in a pathological case where there are midi messages on every sample, then + renderVoices() could be called once per sample and lead to poor performance, so this + setting allows you to set a lower limit on the block size. + + The default setting is 32, which means that midi messages are accurate to about < 1ms + accuracy, which is probably fine for most purposes, but you may want to increase or + decrease this value for your synth. + + If shouldBeStrict is true, the audio sub-blocks will strictly never be smaller than numSamples. + + If shouldBeStrict is false (default), the first audio sub-block in the buffer is allowed + to be smaller, to make sure that the first MIDI event in a buffer will always be sample-accurate + (this can sometimes help to avoid quantisation or phasing issues). + */ + void setMinimumRenderingSubdivisionSize (int numSamples, bool shouldBeStrict = false) noexcept; + + protected: + juce::OwnedArray voices; + juce::ReferenceCountedArray sounds; + + /** The last pitch-wheel values for each midi channel. */ + int lastPitchWheelValues[16]; + + /** Adds a new voice to the synth. + + All the voices should be the same class of object and are treated equally. + + The object passed in will be managed by the synthesiser, which will delete + it later on when no longer needed. The caller should not retain a pointer to the + voice. + */ + LockFreeSynthesiserVoice* addVoice (LockFreeSynthesiserVoice* newVoice); + + /** Adds a new sound to the synthesiser. + + The object passed in is reference counted, so will be deleted when the + synthesiser and all voices are no longer using it. + */ + juce::SynthesiserSound* addSound (const juce::SynthesiserSound::Ptr& newSound); + + /** Renders the voices for the given range. + By default, this just calls renderNextBlock() on each voice, but you may need + to override it to handle custom cases. + */ + virtual void renderVoices (juce::AudioBuffer& outputAudio, + int startSample, + int numSamples); + virtual void renderVoices (juce::AudioBuffer& outputAudio, + int startSample, + int numSamples); + + /** Searches through the voices to find one that's not currently playing, and + which can play the given sound. + + Returns nullptr if all voices are busy and stealing isn't enabled. + + To implement a custom note-stealing algorithm, you can either override this + method, or (preferably) override findVoiceToSteal(). + */ + virtual LockFreeSynthesiserVoice* findFreeVoice (juce::SynthesiserSound* soundToPlay, + int midiChannel, + int midiNoteNumber, + bool stealIfNoneAvailable) const; + + /** Chooses a voice that is most suitable for being re-used. + The default method will attempt to find the oldest voice that isn't the + bottom or top note being played. If that's not suitable for your synth, + you can override this method and do something more cunning instead. + */ + virtual LockFreeSynthesiserVoice* findVoiceToSteal (juce::SynthesiserSound* soundToPlay, + int midiChannel, + int midiNoteNumber) const; + + /** Starts a specified voice playing a particular sound. + You'll probably never need to call this, it's used internally by noteOn(), but + may be needed by subclasses for custom behaviours. + */ + void startVoice (LockFreeSynthesiserVoice* voice, + juce::SynthesiserSound* sound, + int midiChannel, + int midiNoteNumber, + float velocity); + + /** Can be overridden to do custom handling of incoming midi events. */ + virtual void handleMidiEvent (const juce::MidiMessage&); + + private: + //============================================================================== + double sampleRate = 0; + juce::uint32 lastNoteOnCounter = 0; + int minimumSubBlockSize = 32; + bool subBlockSubdivisionIsStrict = false; + bool shouldStealNotes = true; + juce::BigInteger sustainPedalsDown; + mutable juce::Array usableVoicesToStealArray; + + template + void processNextBlock (juce::AudioBuffer&, const juce::MidiBuffer&, int startSample, int numSamples); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (LockFreeSynthesiser) +}; diff --git a/source/DSP/PhatEffectsCrossfadeProcessor.hpp b/source/DSP/PhatEffectsCrossfadeProcessor.hpp index 4383019..adf5c86 100644 --- a/source/DSP/PhatEffectsCrossfadeProcessor.hpp +++ b/source/DSP/PhatEffectsCrossfadeProcessor.hpp @@ -114,21 +114,21 @@ class EffectsCrossfadeProcessor const juce::dsp::ProcessContextReplacing& context) { const auto inputBlock { context.getInputBlock() }; - jassert (previousEffectBuffer.getNumChannels() >= nextEffectBuffer.getNumChannels() - && nextEffectBuffer.getNumChannels() >= inputBlock.getNumChannels()); - jassert (previousEffectBuffer.getNumSamples () >= nextEffectBuffer.getNumSamples () - && nextEffectBuffer.getNumSamples () >= inputBlock.getNumSamples ()); + jassert ((int) previousEffectBuffer.getNumChannels() >= nextEffectBuffer.getNumChannels() + && (int) nextEffectBuffer.getNumChannels() >= inputBlock.getNumChannels()); + jassert ((int) previousEffectBuffer.getNumSamples() >= nextEffectBuffer.getNumSamples() + && (int) nextEffectBuffer.getNumSamples() >= inputBlock.getNumSamples()); const bool needToInverse = juce::approximatelyEqual (smoothedGainL.getTargetValue (), static_cast (1)); T curGain {}; - for (int channel = 0; channel < inputBlock.getNumChannels (); ++channel) + for (int channel = 0; channel < (int) inputBlock.getNumChannels (); ++channel) { const auto* prevData = previousEffectBuffer.getReadPointer (channel); const auto* nextData = nextEffectBuffer.getReadPointer (channel); auto* outData = context.getOutputBlock().getChannelPointer (channel); - for (int sample = 0; sample < inputBlock.getNumSamples(); ++sample) + for (int sample = 0; sample < (int) inputBlock.getNumSamples(); ++sample) { //TODO VB: this could be an IIFE to get curGain //figure out curGain value based on the current channel and whether we're running the smoothedGains in reverse diff --git a/source/DSP/PhatEffectsProcessor.hpp b/source/DSP/PhatEffectsProcessor.hpp index c754b86..ab91277 100644 --- a/source/DSP/PhatEffectsProcessor.hpp +++ b/source/DSP/PhatEffectsProcessor.hpp @@ -175,7 +175,7 @@ void process (const juce::dsp::ProcessContextReplacing& context) jassert (fade_buffer1.getNumSamples() >= numSamples && fade_buffer2.getNumSamples() >= numSamples); //copy the OG buffer into the individual processor ones - for (auto c = 0; c < inputBlock.getNumChannels (); ++c) + for (auto c = 0; c < (int) inputBlock.getNumChannels (); ++c) { //TODO VB: look into copyFromWithRamp!!! we could probably use this to do the crossfade fade_buffer1.copyFrom (c, 0, inputBlock.getChannelPointer (c), numSamples); diff --git a/source/DSP/PhatOscillators.h b/source/DSP/PhatOscillators.h index 35e9e35..a17a779 100644 --- a/source/DSP/PhatOscillators.h +++ b/source/DSP/PhatOscillators.h @@ -288,9 +288,9 @@ void PhatOscillators::updateOscFrequenciesInternal () const auto curOsc2Slop = slopOsc2 * slopMod; const auto osc1FloatNote = static_cast (curMidiNote) - osc1NoteOffset + osc1TuningOffset + lfoOsc1NoteOffset + pitchWheelDeltaNote + curOsc1Slop; - sub.setFrequency (Helpers::getMidiNoteInHertz(osc1FloatNote - 12), true); + sub.setFrequency (Helpers::getMidiNoteInHertz (osc1FloatNote - 12), true); noise.setFrequency (Helpers::getMidiNoteInHertz (osc1FloatNote), true); - osc1.setFrequency (Helpers::getMidiNoteInHertz (osc1FloatNote), true); + osc1.setFrequency (Helpers::getMidiNoteInHertz (osc1FloatNote), true); const auto osc2Freq = Helpers::getMidiNoteInHertz (static_cast (curMidiNote) - osc2NoteOffset + osc2TuningOffset + lfoOsc2NoteOffset + pitchWheelDeltaNote + curOsc2Slop); osc2.setFrequency (osc2Freq, true); diff --git a/source/DSP/ProPhatSynthesiser.h b/source/DSP/ProPhatSynthesiser.h index c4ff3d7..c234180 100644 --- a/source/DSP/ProPhatSynthesiser.h +++ b/source/DSP/ProPhatSynthesiser.h @@ -25,8 +25,8 @@ #if ! EFFECTS_PROCESSOR_PER_VOICE #include "PhatEffectsProcessor.hpp" #endif +#include "LockFreeSynthesiser.h" #include "ProPhatVoice.h" -#include "PhatVerb.h" #include "../Utility/Helpers.h" /** The main Synthesiser for the plugin. It uses Constants::numVoices voices (of type ProPhatVoice), @@ -34,7 +34,7 @@ * state via juce::AudioProcessorValueTreeState::Listener(). */ template -class ProPhatSynthesiser : public juce::Synthesiser, public juce::AudioProcessorValueTreeState::Listener +class ProPhatSynthesiser : public LockFreeSynthesiser, public juce::AudioProcessorValueTreeState::Listener { public: ProPhatSynthesiser (juce::AudioProcessorValueTreeState& processorState); @@ -185,7 +185,7 @@ void ProPhatSynthesiser::noteOn (const int midiChannel, const int midiNoteNum if (voicesBeingKilled.size() >= Constants::numVoices) return; - Synthesiser::noteOn (midiChannel, midiNoteNumber, velocity); + LockFreeSynthesiser::noteOn (midiChannel, midiNoteNumber, velocity); #if LOG_EVERYTHING_AFTER_TRANSITION effectsProcessor.setIsPlaying (true); diff --git a/source/DSP/ProPhatVoice.h b/source/DSP/ProPhatVoice.h index 18215bf..c3c35c4 100644 --- a/source/DSP/ProPhatVoice.h +++ b/source/DSP/ProPhatVoice.h @@ -22,6 +22,7 @@ #pragma once +#include "LockFreeSynthesiser.h" #include "PhatOscillators.h" #include "../UI/ButtonGroupComponent.h" @@ -48,8 +49,11 @@ enum class ProcessorId filterIndex = 0, masterGainIndex, }; + +//============================================================================== + template -class ProPhatVoice : public juce::SynthesiserVoice, public juce::AudioProcessorValueTreeState::Listener +class ProPhatVoice : public LockFreeSynthesiserVoice, public juce::AudioProcessorValueTreeState::Listener { public: ProPhatVoice (juce::AudioProcessorValueTreeState& processorState, int vId, std::set* activeVoiceSet); @@ -63,9 +67,14 @@ class ProPhatVoice : public juce::SynthesiserVoice, public juce::AudioProcessorV void setAmpParam (juce::StringRef parameterID, float newValue); void setFilterEnvParam (juce::StringRef parameterID, float newValue); - void setLfoShape (int shape); + void setLfoShape (LfoShape::Values shape); void setLfoDest (int dest); - void setLfoFreq (float newFreq) { lfo.setFrequency (newFreq); } + void setLfoFreq (float newFreq) + { + //TODO RT: none of this is atomic / thread safe + for (auto& lfo : lfos) + lfo.setFrequency (newFreq); + } void setLfoAmount (float newAmount) { lfoAmount = newAmount; } void setFilterCutoff (T newValue) @@ -160,45 +169,12 @@ class ProPhatVoice : public juce::SynthesiserVoice, public juce::AudioProcessorV T curFilterResonance { Constants::defaultFilterResonance }; //lfo stuff + //TODO #63: how is this actually used? Especially because we always use only the first sample of the lfo process call? static constexpr auto lfoUpdateRate = 100; int lfoUpdateCounter = lfoUpdateRate; - juce::dsp::Oscillator lfo; - - /** TODO RT: implement this pattern for all things that need to be try-locked. Can I abstract/wrap this into an object? - class WavetableSynthesizer - { - public: - void audioCallback() - { - if (std::unique_lock tryLock (mutex, std::try_to_lock); tryLock.owns_lock()) - { - // Do something with wavetable - } - else - { - // Do something else as wavetable is not available - } - } - void updateWavetable () - { - // Create new Wavetable - auto newWavetable = std::make_unique(); - { - std::lock_guard lock (mutex); - std::swap (wavetable, newWavetable); - } - - // Delete old wavetable here to lock for least time possible - } - - private: - spin_lock mutex; - std::unique_ptr wavetable; - }; - } - */ - std::mutex lfoMutex; + std::array, LfoShape::totalSelectable> lfos; + std::atomic*> curLfo {nullptr}; //TODO RT: I think this (and all similar parameters set in the UI and read in the audio thread) sould be atomic T lfoAmount = static_cast (Constants::defaultLfoAmount); @@ -232,8 +208,35 @@ ProPhatVoice::ProPhatVoice (juce::AudioProcessorValueTreeState& processorStat lfoDest.curSelection = (int) defaultLfoDest; + lfos[LfoShape::triangle].initialise ([] (T x) { return (std::sin (x) + 1) / 2; }, 128); + lfos[LfoShape::saw].initialise ([] (T x) { return juce::jmap (x, -juce::MathConstants::pi, juce::MathConstants::pi, T (0), T (1)); }, 2); + //lfos[LfoShape::revSaw].initialise ([] (T x) { return (float) juce::jmap (x, -juce::MathConstants::pi, juce::MathConstants::pi, 1.f, 0.f); }, 2); + lfos[LfoShape::square].initialise ([] (T x) { return x < 0 ? T (0) : T (1); }); + + // TODO #63: because we don't give the lookupTableNumPoints parameter to the lfo, we're not building a lookup table, and we can't because of the state logic. + // So this means this function takes longer than the other ones. Which is only called once per buffer, so probably not a big deal, but the whole logic is kinda + // weird, so maybe we should refactor it later. + lfos[LfoShape::randomLfo].initialise ([this](T x) + { + if (x <= 0.f && valueWasBig) + { + randomValue = rng.nextFloat ()/* * 2 - 1*/; + valueWasBig = false; + } + else if (x > 0.f && ! valueWasBig) + { + randomValue = rng.nextFloat ()/* * 2 - 1*/; + valueWasBig = true; + } + + return randomValue; }); + setLfoShape (LfoShape::triangle); - lfo.setFrequency (Constants::defaultLfoFreq); + for (auto& lfo : lfos) + { + jassert (lfo.isInitialised()); + lfo.setFrequency (Constants::defaultLfoFreq); + } } template @@ -343,7 +346,8 @@ void ProPhatVoice::prepare (const juce::dsp::ProcessSpec& spec) filterADSR.setSampleRate (spec.sampleRate); filterADSR.setParameters (filterEnvParams); - lfo.prepare ({ spec.sampleRate / lfoUpdateRate, spec.maximumBlockSize, spec.numChannels }); + for (auto& lfo : lfos) + lfo.prepare ({ spec.sampleRate / lfoUpdateRate, spec.maximumBlockSize, spec.numChannels }); #if EFFECTS_PROCESSOR_PER_VOICE effectsProcessor.prepare (spec); @@ -412,13 +416,12 @@ void ProPhatVoice::parameterChanged (const juce::String& parameterID, float n || parameterID == filterEnvReleaseID.getParamID()) setFilterEnvParam (parameterID, newValue); - //TODO RT: need to use try_locks for both of these else if (parameterID == lfoShapeID.getParamID()) - setLfoShape ((int) newValue); + setLfoShape (static_cast (newValue)); else if (parameterID == lfoDestID.getParamID()) setLfoDest ((int) newValue); - //TODO RT: I think because all of these end up setting smoothed values, it's fine to set them directly + //TODO RT: need to go through these and figure out what needs to be atomic, set async on the audio thread, or ramped or whatever it is else if (parameterID == lfoAmountID.getParamID()) setLfoAmount (newValue); else if (parameterID == lfoFreqID.getParamID()) @@ -500,81 +503,18 @@ void ProPhatVoice::setFilterEnvParam (juce::StringRef parameterID, float newV filterADSR.setParameters (filterEnvParams); } -//@TODO For now, all lfos oscillate between [0, 1], even though the random one (and only that one) should oscilate between [-1, 1] +//@TODO #63 For now, all lfos oscillate between [0, 1], even though the random one (and only that one) should oscilate between [-1, 1] template -void ProPhatVoice::setLfoShape (int shape) +void ProPhatVoice::setLfoShape (LfoShape::Values shape) { switch (shape) { - case LfoShape::triangle: - { - //TODO RT: I should really have 4 lfos and just have an atomic curLfo pointer that I swap when I change LFOs - std::lock_guard lock (lfoMutex); - lfo.initialise ([] (T x) - { return (std::sin (x) + 1) / 2; }, - 128); - } - break; - - case LfoShape::saw: - { - std::lock_guard lock (lfoMutex); - lfo.initialise ([] (T x) - { - //this is a sawtooth wave; as x goes from -pi to pi, y goes from -1 to 1 - return juce::jmap (x, -juce::MathConstants::pi, juce::MathConstants::pi, T { 0 }, T { 1 }); }, - 2); - } - break; - - //TODO add this once we have more room in the UI for lfo destinations - /* - case LfoShape::revSaw: - { - std::lock_guard lock (lfoMutex); - lfo.initialise ([](float x) - { - return (float) juce::jmap (x, -juce::MathConstants::pi, juce::MathConstants::pi, 1.f, 0.f); - }, 2); - } - break; - */ - - case LfoShape::square: - { - std::lock_guard lock (lfoMutex); - lfo.initialise ([] (T x) - { - if (x < 0) - return T { 0 }; - else - return T { 1 }; }); - } - break; - - case LfoShape::randomLfo: - { - std::lock_guard lock (lfoMutex); - lfo.initialise ([this] (T x) - { - if (x <= 0.f && valueWasBig) - { - randomValue = rng.nextFloat()/* * 2 - 1*/; - valueWasBig = false; - } - else if (x > 0.f && ! valueWasBig) - { - randomValue = rng.nextFloat()/* * 2 - 1*/; - valueWasBig = true; - } - - return randomValue; }); - } - break; - - default: - jassertfalse; - break; + case LfoShape::triangle: curLfo.store (&lfos[LfoShape::triangle]); break; + case LfoShape::saw: curLfo.store (&lfos[LfoShape::saw]); break; + //case LfoShape::revSaw: curLfo.store (&lfos[LfoShape::revSaw]); break; + case LfoShape::square: curLfo.store (&lfos[LfoShape::square]); break; + case LfoShape::randomLfo: curLfo.store (&lfos[LfoShape::randomLfo]); break; + default: jassertfalse; } } @@ -584,7 +524,6 @@ void ProPhatVoice::setLfoDest (int dest) //reset everything oscillators.resetLfoOscNoteOffsets(); - //TODO RT: this should also be behind a lock/try_lock //change the destination lfoDest.curSelection = dest; } @@ -593,17 +532,8 @@ void ProPhatVoice::setLfoDest (int dest) template void ProPhatVoice::updateLfo() { - T lfoOut; - { - std::unique_lock tryLock (lfoMutex, std::defer_lock); - if (! tryLock.try_lock()) - { - //TODO RT: I need to fade out or something when we can't acquire the lock - return; - } - - lfoOut = lfo.processSample (T (0)) * lfoAmount; - } + //TODO: so err, wat? we're only using the first sample of the lfo at every buffer size? So the lfo speed is tied to the buffer size? + const auto lfoOut { curLfo.load()->processSample (static_cast (0)) * lfoAmount }; //TODO get this switch out of here, this is awful for performances switch (lfoDest.curSelection) diff --git a/source/Utility/Helpers.h b/source/Utility/Helpers.h index c393ca4..8ecd98d 100644 --- a/source/Utility/Helpers.h +++ b/source/Utility/Helpers.h @@ -219,21 +219,26 @@ constexpr auto effect3 { "Phaser" }; struct Selection { - Selection () = default; - virtual ~Selection () = default; - Selection (const Selection&) = default; - Selection& operator= (const Selection&) = default; - Selection (Selection&&) noexcept = default; - Selection& operator= (Selection&&) noexcept = default; + Selection() = default; + virtual ~Selection() = default; - Selection (int selection) : curSelection (selection) {} + Selection (int selection) { curSelection.store (selection); } + Selection (const Selection& s) { curSelection.store (s.curSelection.load()); } - int curSelection = 0; + Selection& operator= (const Selection& s) + { + if (this != &s) + curSelection.store (s.curSelection.load()); + return *this; + } + + Selection (Selection&& s) noexcept = delete; + Selection& operator= (Selection&& s) = delete; + + std::atomic curSelection { 0 }; - //TODO: getLastSelectionIndex() is virtual but it is the same in all children -- is there a way to - //have totalSelectable declared in the parent somehow? - virtual int getLastSelectionIndex () = 0; - virtual bool isNullSelectionAllowed () = 0; + virtual int getLastSelectionIndex() = 0; + virtual bool isNullSelectionAllowed() = 0; }; struct OscShape : public Selection @@ -246,7 +251,8 @@ struct OscShape : public Selection triangle, pulse, totalSelectable, - noise // noise needs to be after totalSelectable, because it's not selectable with the regular oscillators + noise, // noise needs to be after totalSelectable, because it's not selectable with the regular oscillators + actualTotal }; int getLastSelectionIndex () override { return totalSelectable - 1; } @@ -255,7 +261,7 @@ struct OscShape : public Selection struct LfoShape : public Selection { - enum + enum Values { triangle = 0, saw, @@ -271,7 +277,7 @@ struct LfoShape : public Selection struct LfoDest : public Selection { - enum + enum Values { osc1Freq = 0, osc2Freq, @@ -287,7 +293,7 @@ struct LfoDest : public Selection struct SelectedEffect : public Selection { //this is essentially like EffectType, so we still need that enum? - enum + enum Values { none = 0, verb,