chore: land janitor round 1 and graft integration
Round 1 findings and fixes, all previously uncommitted: - FXProcessor per-slot DSP history repair (HEAD does not compile without it) - SynthVoice::getModulationValue, editor control-lock guard - ExactCompare.h naming the deliberate exact float comparisons - KnobLookAndFeel replacing the never-installed SerumLookAndFeel - 55 first-party compiler warnings to 0 - AGENT.md renamed to AGENTS.md across Source/ - graft repository context graph and its hooks - JANITOR_TASKS.md and UI_UX_AUDIT.md records
This commit is contained in:
1 parent
5c942cad0c
commit
e4cea300db
63 files changed
+2360
-805
No files matched your search
@@ -50,4 +50,4 @@ void prepare (double sampleRate, int) override
|
||||
}
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -28,7 +28,7 @@ public:
|
||||
|
||||
void setLowpass (double freq, double q)
|
||||
{
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double w0 = juce::MathConstants<double>::twoPi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / (2.0 * q);
|
||||
@@ -42,7 +42,7 @@ public:
|
||||
|
||||
void setHighpass (double freq, double q)
|
||||
{
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double w0 = juce::MathConstants<double>::twoPi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / (2.0 * q);
|
||||
@@ -57,7 +57,7 @@ public:
|
||||
void setLowShelf (double freq, double gainDb)
|
||||
{
|
||||
const double a = std::pow (10.0, gainDb / 40.0);
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double w0 = juce::MathConstants<double>::twoPi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / 2.0 * std::sqrt (2.0);
|
||||
@@ -72,7 +72,7 @@ public:
|
||||
void setHighShelf (double freq, double gainDb)
|
||||
{
|
||||
const double a = std::pow (10.0, gainDb / 40.0);
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double w0 = juce::MathConstants<double>::twoPi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / 2.0 * std::sqrt (2.0);
|
||||
@@ -87,7 +87,7 @@ public:
|
||||
void setPeak (double freq, double gainDb, double q)
|
||||
{
|
||||
const double a = std::pow (10.0, gainDb / 40.0);
|
||||
const double w0 = 2.0 * juce::MathConstants<double>::pi * freq / sampleRate;
|
||||
const double w0 = juce::MathConstants<double>::twoPi * freq / sampleRate;
|
||||
const double cw = std::cos (w0);
|
||||
const double sw = std::sin (w0);
|
||||
const double alpha = sw / (2.0 * q);
|
||||
|
||||
@@ -37,13 +37,13 @@ void ChorusUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
delayL[(size_t) writePos] = inL;
|
||||
delayR[(size_t) writePos] = inR;
|
||||
|
||||
lfoPhase += 6.28318530717958647692 * rate / sr;
|
||||
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
|
||||
lfoPhase += juce::MathConstants<double>::twoPi * rate / sr;
|
||||
if (lfoPhase > juce::MathConstants<double>::twoPi) lfoPhase -= juce::MathConstants<double>::twoPi;
|
||||
|
||||
float sumL = 0.0f, sumR = 0.0f;
|
||||
for (int v = 0; v < 2; ++v)
|
||||
{
|
||||
const double mod = std::sin (lfoPhase + v * 3.14159265358979323846) * depth;
|
||||
const double mod = std::sin (lfoPhase + v * juce::MathConstants<double>::pi) * depth;
|
||||
const double d = base + mod + (v == 1 ? width * depth * 0.5 : 0.0);
|
||||
double readPos = writePos - d;
|
||||
if (readPos < 0.0) readPos += len;
|
||||
|
||||
@@ -35,12 +35,16 @@ void FlangerUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
{
|
||||
const float inL = l[i], inR = r[i];
|
||||
|
||||
lfoPhase += 6.28318530717958647692 * rate / sr;
|
||||
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
|
||||
lfoPhase += juce::MathConstants<double>::twoPi * rate / sr;
|
||||
if (lfoPhase > juce::MathConstants<double>::twoPi) lfoPhase -= juce::MathConstants<double>::twoPi;
|
||||
const double mod = std::sin (lfoPhase) * depth;
|
||||
|
||||
// base + mod is not guaranteed positive: at high depth the LFO offset
|
||||
// can exceed the centre delay, so the read position runs ahead of the
|
||||
// write position and needs the wrap in both directions to stay in range.
|
||||
double readPos = writePos - (base + mod);
|
||||
if (readPos < 0.0) readPos += len;
|
||||
if (readPos >= (double) len) readPos -= len;
|
||||
const int i0 = (int) readPos;
|
||||
const int i1 = (i0 + 1) % len;
|
||||
const float frac = (float) (readPos - std::floor (readPos));
|
||||
|
||||
@@ -27,11 +27,15 @@ void PhaserUnit::process (float* l, float* r, int numSamples, const float p[4])
|
||||
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
lfoPhase += 6.28318530717958647692 * rate / sr;
|
||||
if (lfoPhase > 6.28318530717958647692) lfoPhase -= 6.28318530717958647692;
|
||||
lfoPhase += juce::MathConstants<double>::twoPi * rate / sr;
|
||||
if (lfoPhase > juce::MathConstants<double>::twoPi) lfoPhase -= juce::MathConstants<double>::twoPi;
|
||||
|
||||
const float sweep = (float) (0.5 + 0.5 * std::sin (lfoPhase));
|
||||
const float a = 0.3f + depth * sweep; // allpass coefficient
|
||||
// The allpass cascade is stable only while the coefficient stays inside
|
||||
// (-1, 1). depth can push 0.3 + depth * sweep above 1, which makes every
|
||||
// stage self-excite, so the filter state runs away to inf/NaN and keeps
|
||||
// producing it. Clamp at the point where the coefficient is computed.
|
||||
const float a = juce::jlimit (0.0f, 0.99f, 0.3f + depth * sweep);
|
||||
|
||||
float outL = l[i] + yL[5] * feedback;
|
||||
float outR = r[i] + yR[5] * feedback;
|
||||
|
||||
+15
-6
@@ -56,16 +56,30 @@ void Engine::reset()
|
||||
fx.reset();
|
||||
pitchBend = 0.0f;
|
||||
modWheel = 0.0f;
|
||||
activeVoiceCount.store (0, std::memory_order_relaxed);
|
||||
mixBuffer.clear();
|
||||
}
|
||||
|
||||
void Engine::requestReset() noexcept
|
||||
{
|
||||
const juce::ScopedLock lock (controlLock);
|
||||
resetRequested = true;
|
||||
}
|
||||
|
||||
void Engine::captureControls()
|
||||
{
|
||||
const juce::ScopedTryLock lock (controlLock);
|
||||
if (! lock.isLocked())
|
||||
return;
|
||||
|
||||
// A preset change asks for a clean start. reset() runs here, on the audio
|
||||
// thread, because the message thread must never touch the voice, filter or
|
||||
// FX state the render is reading.
|
||||
if (resetRequested)
|
||||
{
|
||||
resetRequested = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
audioMatrix.connections.assign (matrix.connections.begin(),
|
||||
matrix.connections.begin() + juce::jmin (matrix.size(), ModulationMatrix::kMaxConnections));
|
||||
for (int i = 0; i < kNumMacros; ++i)
|
||||
@@ -356,11 +370,6 @@ void Engine::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& m
|
||||
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
|
||||
+7
-3
@@ -25,6 +25,12 @@ public:
|
||||
void prepare (double sampleRate, int blockSize, juce::AudioProcessorValueTreeState& apvts);
|
||||
void reset();
|
||||
|
||||
// Ask for the DSP state to be cleared before the next block is rendered.
|
||||
// Safe to call from the message thread while controlLock is held: the audio
|
||||
// thread performs the reset in captureControls, so the DSP objects are only
|
||||
// ever touched by the audio thread.
|
||||
void requestReset() noexcept;
|
||||
|
||||
void processBlock (juce::AudioBuffer<float>& buffer,
|
||||
juce::MidiBuffer& midi,
|
||||
juce::AudioProcessorValueTreeState& apvts,
|
||||
@@ -43,8 +49,6 @@ public:
|
||||
const WavetableLibrary& getWavetables() const { return wavetables; }
|
||||
void setLfoShapeData (int index, const std::vector<float>& data, int steps);
|
||||
|
||||
int getActiveVoiceCount() const { return activeVoiceCount.load (std::memory_order_relaxed); }
|
||||
|
||||
private:
|
||||
std::array<SynthVoice, kNumVoices> voices;
|
||||
std::array<LFO, kNumLfos> lfos, controlLfos;
|
||||
@@ -53,6 +57,7 @@ private:
|
||||
ModulationMatrix matrix, audioMatrix;
|
||||
MacroControls macros, audioMacros;
|
||||
juce::CriticalSection controlLock;
|
||||
bool resetRequested = false; // guarded by controlLock
|
||||
|
||||
struct ParameterValue
|
||||
{
|
||||
@@ -67,7 +72,6 @@ private:
|
||||
float pitchBend = 0.0f;
|
||||
float modWheel = 0.0f;
|
||||
juce::uint64 noteCounter = 0;
|
||||
std::atomic<int> activeVoiceCount { 0 };
|
||||
|
||||
juce::AudioBuffer<float> mixBuffer;
|
||||
|
||||
|
||||
+2
-2
@@ -41,8 +41,8 @@ void Envelope::setParams (float attackSec, float decaySec, float sustainLevel, f
|
||||
releaseSamples = std::max (1.0, (double) releaseSec * sr);
|
||||
|
||||
// curve 0 -> fast attack / long release curve; curve 1 -> slow attack / fast decay
|
||||
attackShape = 0.3 + curve * 2.7; // 0.3 .. 3.0
|
||||
decayShape = 3.0 - curve * 2.7; // 3.0 .. 0.3
|
||||
attackShape = (double) attackShapeFor (curve);
|
||||
decayShape = (double) decayShapeFor (curve);
|
||||
|
||||
shape.attack = attackSec;
|
||||
shape.decay = decaySec;
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// Curve-to-shape mapping shared by the envelope DSP and the GUI preview, so the
|
||||
// two cannot drift apart.
|
||||
inline float attackShapeFor (float curve) noexcept { return 0.3f + curve * 2.7f; } // 0.3 .. 3.0
|
||||
inline float decayShapeFor (float curve) noexcept { return 3.0f - curve * 2.7f; } // 3.0 .. 0.3
|
||||
|
||||
// ===========================================================================
|
||||
// ADSR envelope with curve tension. Durations are in seconds; the sustain
|
||||
// level is 0..1. Retriggering (noteOn while active) starts from the current
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
// Exact floating-point comparison helpers.
|
||||
//
|
||||
// The engine caches derived state - filter coefficients, per-voice parameter
|
||||
// snapshots, GUI preview values - and re-derives it only when a stored value
|
||||
// changes. Those checks must stay bit-exact: an epsilon compare would either
|
||||
// keep stale cached state or skip a needed rebuild near the threshold, so the
|
||||
// intent is named here instead of weakened at each call site.
|
||||
//
|
||||
// Every first-party use of == / != on floating point goes through one of these
|
||||
// helpers, and the single -Wfloat-equal suppression lives here rather than
|
||||
// being repeated across the call sites.
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wfloat-equal"
|
||||
#endif
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// Exact equality, no epsilon. Used where the exact value carries meaning, such
|
||||
// as "the output level is exactly unity" or "the untouched tail is the zero
|
||||
// default".
|
||||
inline bool exactly (float a, float b) noexcept { return a == b; }
|
||||
inline bool exactly (double a, double b) noexcept { return a == b; }
|
||||
|
||||
// Exact inequality, for "the cached value changed" tests.
|
||||
inline bool changed (float a, float b) noexcept { return a != b; }
|
||||
inline bool changed (double a, double b) noexcept { return a != b; }
|
||||
|
||||
// True when a stored double that should hold a whole number really does.
|
||||
inline bool isWholeNumber (double v) noexcept { return std::floor (v) == v; }
|
||||
|
||||
} // namespace serum
|
||||
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
#pragma GCC diagnostic pop
|
||||
#endif
|
||||
+30
-31
@@ -33,17 +33,18 @@ FXProcessor::~FXProcessor() = default;
|
||||
void FXProcessor::prepare (double sampleRate, int maxBlockSize)
|
||||
{
|
||||
for (auto& slotUnits : units)
|
||||
for (auto& u : slotUnits)
|
||||
u->prepare (sampleRate, maxBlockSize);
|
||||
for (auto& unit : slotUnits)
|
||||
unit->prepare (sampleRate, maxBlockSize);
|
||||
wet.setSize (2, maxBlockSize, false, false, true);
|
||||
reset();
|
||||
}
|
||||
|
||||
void FXProcessor::reset()
|
||||
{
|
||||
for (auto& u : units)
|
||||
u->reset();
|
||||
dry.clear();
|
||||
for (auto& slotUnits : units)
|
||||
for (auto& unit : slotUnits)
|
||||
unit->reset();
|
||||
activeTypes.fill ((int) FxType::Off);
|
||||
wet.clear();
|
||||
}
|
||||
|
||||
@@ -61,52 +62,50 @@ juce::String FXProcessor::fxTypeName (int type)
|
||||
case FxType::Compressor: return "Compressor";
|
||||
case FxType::Delay: return "Delay";
|
||||
case FxType::Reverb: return "Reverb";
|
||||
case FxType::Count: return {};
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
void FXProcessor::process (juce::AudioBuffer<float>& buffer, const FxSlotParams* slots, int numSlots)
|
||||
{
|
||||
// The rack is stereo end to end; a mono buffer would read a channel that is not there.
|
||||
jassert (buffer.getNumChannels() >= 2);
|
||||
if (buffer.getNumChannels() < 2)
|
||||
return;
|
||||
|
||||
const int n = buffer.getNumSamples();
|
||||
jassert (n <= wet.getNumSamples());
|
||||
if (n <= 0)
|
||||
return;
|
||||
|
||||
for (int s = 0; s < numSlots; ++s)
|
||||
for (int s = 0; s < juce::jmin (numSlots, kNumFxSlots); ++s)
|
||||
{
|
||||
const FxSlotParams& slot = slots[s];
|
||||
if (slot.type == (int) FxType::Off)
|
||||
continue;
|
||||
|
||||
const int unitIdx = slot.type - 1;
|
||||
if (unitIdx < 0 || unitIdx >= (int) units.size())
|
||||
if (unitIdx < 0 || unitIdx >= kNumEffectTypes)
|
||||
{
|
||||
activeTypes[(size_t) s] = (int) FxType::Off;
|
||||
continue;
|
||||
}
|
||||
|
||||
dry.copyFrom (0, 0, buffer, 0, 0, n);
|
||||
dry.copyFrom (1, 1, buffer, 1, 0, n);
|
||||
auto& unit = *units[(size_t) s][(size_t) unitIdx];
|
||||
if (activeTypes[(size_t) s] != slot.type)
|
||||
{
|
||||
unit.reset();
|
||||
activeTypes[(size_t) s] = slot.type;
|
||||
}
|
||||
wet.copyFrom (0, 0, buffer, 0, 0, n);
|
||||
wet.copyFrom (1, 1, buffer, 1, 0, n);
|
||||
|
||||
units[(size_t) unitIdx]->process (wet.getWritePointer (0), wet.getWritePointer (1), n, slot.p);
|
||||
wet.copyFrom (1, 0, buffer, 1, 0, n);
|
||||
unit.process (wet.getWritePointer (0), wet.getWritePointer (1), n, slot.p);
|
||||
|
||||
const float mix = juce::jlimit (0.0f, 1.0f, slot.mix);
|
||||
if (mix >= 1.0f)
|
||||
for (int ch = 0; ch < 2; ++ch)
|
||||
{
|
||||
buffer.copyFrom (0, 0, wet, 0, 0, n);
|
||||
buffer.copyFrom (1, 0, wet, 1, 0, n);
|
||||
}
|
||||
else if (mix > 0.0f)
|
||||
{
|
||||
const float* dL = dry.getReadPointer (0);
|
||||
const float* dR = dry.getReadPointer (1);
|
||||
const float* wL = wet.getReadPointer (0);
|
||||
const float* wR = wet.getReadPointer (1);
|
||||
float* oL = buffer.getWritePointer (0);
|
||||
float* oR = buffer.getWritePointer (1);
|
||||
float* out = buffer.getWritePointer (ch);
|
||||
const float* effect = wet.getReadPointer (ch);
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
oL[i] = dL[i] * (1.0f - mix) + wL[i] * mix;
|
||||
oR[i] = dR[i] * (1.0f - mix) + wR[i] * mix;
|
||||
}
|
||||
out[i] = out[i] * (1.0f - mix) + effect[i] * mix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-32
@@ -1,4 +1,5 @@
|
||||
#include "Filter.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
@@ -18,14 +19,13 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
void Filter::prepare (double sampleRate, int maxBlockSize)
|
||||
void Filter::prepare (double sampleRate)
|
||||
{
|
||||
sr = sampleRate;
|
||||
combLine.assign ((size_t) sampleRate, 0.0f); // 1 second of delay
|
||||
combWrite = 0;
|
||||
combDamp = 0.0;
|
||||
reset();
|
||||
(void) maxBlockSize;
|
||||
}
|
||||
|
||||
void Filter::reset()
|
||||
@@ -38,14 +38,22 @@ void Filter::reset()
|
||||
a1 = a2 = a3 = 0.0;
|
||||
stage.fill (0.0);
|
||||
for (auto& s : formantState) s.fill (0.0);
|
||||
std::fill (combLine.begin(), combLine.end(), 0.0f);
|
||||
// combLine is one second of samples per filter, and a preset change now
|
||||
// resets on the audio thread. It only needs clearing when the comb has
|
||||
// actually written to it.
|
||||
if (combDirty)
|
||||
{
|
||||
std::fill (combLine.begin(), combLine.end(), 0.0f);
|
||||
combDirty = false;
|
||||
}
|
||||
combWrite = 0;
|
||||
combDamp = 0.0;
|
||||
}
|
||||
|
||||
void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept
|
||||
{
|
||||
if (cutoffHz == lastCutoffHz && res == lastRes && type == lastType)
|
||||
// Cache key: rebuild coefficients only when a parameter actually changes.
|
||||
if (! changed (cutoffHz, lastCutoffHz) && ! changed (res, lastRes) && type == lastType)
|
||||
return;
|
||||
lastCutoffHz = cutoffHz;
|
||||
lastRes = res;
|
||||
@@ -89,6 +97,8 @@ void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept
|
||||
updateSvf (g, k);
|
||||
break;
|
||||
}
|
||||
case FilterModel::Comb:
|
||||
case FilterModel::Count:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -96,7 +106,8 @@ void Filter::updateCoefficients (float cutoffHz, float res, int type) noexcept
|
||||
|
||||
void Filter::updateSvf (double g, double k) noexcept
|
||||
{
|
||||
if (g == lastG && k == lastK)
|
||||
// Cache key: the SVF coefficients only change with g and k.
|
||||
if (! changed (g, lastG) && ! changed (k, lastK))
|
||||
return;
|
||||
lastG = g;
|
||||
lastK = k;
|
||||
@@ -105,17 +116,6 @@ void Filter::updateSvf (double g, double k) noexcept
|
||||
a3 = g * a2;
|
||||
}
|
||||
|
||||
double Filter::svfLow (double in, double g, double k) noexcept
|
||||
{
|
||||
updateSvf (g, k);
|
||||
const double v3 = in - ic2eq;
|
||||
const double v1 = a1 * ic1eq + a2 * v3;
|
||||
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
|
||||
ic1eq = 2.0 * v1 - ic1eq;
|
||||
ic2eq = 2.0 * v2 - ic2eq;
|
||||
return v2;
|
||||
}
|
||||
|
||||
double Filter::svfBand (double in, double g, double k) noexcept
|
||||
{
|
||||
updateSvf (g, k);
|
||||
@@ -127,17 +127,6 @@ double Filter::svfBand (double in, double g, double k) noexcept
|
||||
return v1;
|
||||
}
|
||||
|
||||
double Filter::svfHigh (double in, double g, double k) noexcept
|
||||
{
|
||||
updateSvf (g, k);
|
||||
const double v3 = in - ic2eq;
|
||||
const double v1 = a1 * ic1eq + a2 * v3;
|
||||
const double v2 = ic2eq + a2 * ic1eq + a3 * v3;
|
||||
ic1eq = 2.0 * v1 - ic1eq;
|
||||
ic2eq = 2.0 * v2 - ic2eq;
|
||||
return in - k * v1 - v2;
|
||||
}
|
||||
|
||||
double Filter::ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept
|
||||
{
|
||||
stages = juce::jlimit (1, 4, stages);
|
||||
@@ -193,6 +182,7 @@ double Filter::comb (double in, double freqHz, double res, double drive) noexcep
|
||||
const float feedback = clampF ((float) res * 0.9f, 0.0f, 0.98f);
|
||||
combLine[(size_t) combWrite] = (float) in + (float) combDamp * feedback;
|
||||
combWrite = (combWrite + 1) % len;
|
||||
combDirty = true;
|
||||
|
||||
return (double) y;
|
||||
}
|
||||
@@ -204,12 +194,12 @@ double Filter::formant (double in) noexcept
|
||||
for (int i = 0; i < 3; ++i)
|
||||
{
|
||||
const auto& c = formantCoefficients[(size_t) i];
|
||||
const double a1 = c[0];
|
||||
const double a2 = c[1];
|
||||
const double a3 = c[2];
|
||||
const double c0 = c[0];
|
||||
const double c1 = c[1];
|
||||
const double c2 = c[2];
|
||||
const double v3 = in - formantState[(size_t) i][1];
|
||||
const double v1 = a1 * formantState[(size_t) i][0] + a2 * v3;
|
||||
const double v2 = formantState[(size_t) i][1] + a2 * formantState[(size_t) i][0] + a3 * v3;
|
||||
const double v1 = c0 * formantState[(size_t) i][0] + c1 * v3;
|
||||
const double v2 = formantState[(size_t) i][1] + c1 * formantState[(size_t) i][0] + c2 * v3;
|
||||
formantState[(size_t) i][0] = 2.0 * v1 - formantState[(size_t) i][0];
|
||||
formantState[(size_t) i][1] = 2.0 * v2 - formantState[(size_t) i][1];
|
||||
out += v1 * gains[i];
|
||||
@@ -269,6 +259,7 @@ float Filter::processSample (float in, float cutoffHz, float res, float drive, i
|
||||
case FilterModel::Screamer:
|
||||
out = screamer (in, drive);
|
||||
break;
|
||||
case FilterModel::Count:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
+2
-3
@@ -14,7 +14,7 @@ namespace serum
|
||||
class Filter
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize);
|
||||
void prepare (double sampleRate);
|
||||
void reset();
|
||||
|
||||
// Process a mono buffer in-place. cutoffNorm is 0..1 (mapped to 20Hz..20kHz).
|
||||
@@ -43,6 +43,7 @@ private:
|
||||
std::vector<float> combLine;
|
||||
int combWrite = 0;
|
||||
double combDamp = 0.0;
|
||||
bool combDirty = false; // set once comb() has written to combLine
|
||||
|
||||
// Formant: three parallel bandpass SVFs (state pairs).
|
||||
std::array<std::array<double, 2>, 3> formantState { { { { 0.0, 0.0 } }, { { 0.0, 0.0 } }, { { 0.0, 0.0 } } } };
|
||||
@@ -50,9 +51,7 @@ private:
|
||||
|
||||
void updateCoefficients (float cutoffHz, float res, int type) noexcept;
|
||||
void updateSvf (double g, double k) noexcept;
|
||||
double svfLow (double in, double g, double k) noexcept;
|
||||
double svfBand (double in, double g, double k) noexcept;
|
||||
double svfHigh (double in, double g, double k) noexcept;
|
||||
double ladder (double in, double g, double res, double drive, int stages, bool diode) noexcept;
|
||||
double comb (double in, double freqHz, double res, double drive) noexcept;
|
||||
double formant (double in) noexcept;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
#include "FilterBank.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void FilterBank::prepare (double sampleRate, int maxBlockSize)
|
||||
void FilterBank::prepare (double sampleRate)
|
||||
{
|
||||
f1L.prepare (sampleRate, maxBlockSize);
|
||||
f1R.prepare (sampleRate, maxBlockSize);
|
||||
f2L.prepare (sampleRate, maxBlockSize);
|
||||
f2R.prepare (sampleRate, maxBlockSize);
|
||||
f1L.prepare (sampleRate);
|
||||
f1R.prepare (sampleRate);
|
||||
f2L.prepare (sampleRate);
|
||||
f2R.prepare (sampleRate);
|
||||
}
|
||||
|
||||
void FilterBank::reset()
|
||||
@@ -67,8 +68,9 @@ void FilterBank::process (float* l, float* r, int numSamples, const FilterBankPa
|
||||
applySlot (f2R, r, numSamples, p.f2On, p.f2Type, p.f2Cutoff, p.f2Res, p.f2Drive, p.f2Key, p.f2Slope, noteHz);
|
||||
}
|
||||
|
||||
// Post-filter output level.
|
||||
if (p.out != 1.0f)
|
||||
// Post-filter output level. Skip the multiply only at exact unity so the
|
||||
// bypass never changes the rendered samples.
|
||||
if (! exactly (p.out, 1.0f))
|
||||
{
|
||||
for (int i = 0; i < numSamples; ++i)
|
||||
{
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ struct FilterBankParams
|
||||
class FilterBank
|
||||
{
|
||||
public:
|
||||
void prepare (double sampleRate, int maxBlockSize);
|
||||
void prepare (double sampleRate);
|
||||
void reset();
|
||||
|
||||
void process (float* l, float* r, int numSamples, const FilterBankParams& p, float noteHz) noexcept;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
## Owned files
|
||||
|
||||
- Related editor implementation: `../PluginEditor.h`, `../PluginEditor.cpp`
|
||||
- SerumLookAndFeel.h
|
||||
- Knob.h, Knob.cpp
|
||||
- Slider.h, Slider.cpp
|
||||
- ToggleButton.h, ToggleButton.cpp
|
||||
@@ -16,7 +15,6 @@
|
||||
|
||||
## Rules
|
||||
|
||||
- Draw all controls through `SerumLookAndFeel`.
|
||||
- Use `theme::` colours from Resources.h instead of hardcoded colour values.
|
||||
- Own GUI attachments with `std::unique_ptr`.
|
||||
- Own component children by value or with `std::unique_ptr`; `addAndMakeVisible` only attaches and shows them, and does not manage their lifetime.
|
||||
@@ -44,4 +42,4 @@ const float v = processor.parameters.getRawParameterValue (ids::f1Cutoff)
|
||||
: 0.0f;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -1,11 +1,14 @@
|
||||
#include "EnvelopeDisplay.h"
|
||||
#include "../Envelope.h"
|
||||
#include "../ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void EnvelopeDisplay::setParams (float a, float d, float s, float r, float c)
|
||||
{
|
||||
if (attack == a && decay == d && sustain == s && release == r && curve == c)
|
||||
if (! changed (attack, a) && ! changed (decay, d) && ! changed (sustain, s)
|
||||
&& ! changed (release, r) && ! changed (curve, c))
|
||||
return;
|
||||
attack = a; decay = d; sustain = s; release = r; curve = c;
|
||||
repaint();
|
||||
@@ -19,8 +22,8 @@ void EnvelopeDisplay::paint (juce::Graphics& g)
|
||||
g.setColour (theme::outline);
|
||||
g.drawRoundedRectangle (b.expanded (4.0f).reduced (0.5f), 4.0f, 1.0f);
|
||||
|
||||
const float atkShape = 0.3f + curve * 2.7f;
|
||||
const float decShape = 3.0f - curve * 2.7f;
|
||||
const float atkShape = attackShapeFor (curve);
|
||||
const float decShape = decayShapeFor (curve);
|
||||
|
||||
// Normalise durations for display, allowing a short sustain plateau.
|
||||
const float aSec = maps::toSeconds (attack);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
#include "FilterDisplay.h"
|
||||
#include "../ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// Schematic frequency-response preview for this view only. It does not model the
|
||||
// real DSP in Source/Filter.cpp, nor the drive, the slope or modulation, so it is
|
||||
// not expected to match the nonlinear filter numerically.
|
||||
float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int type)
|
||||
{
|
||||
const float w = (cutoffHz > 1.0f) ? freqHz / cutoffHz : 1.0f;
|
||||
@@ -29,7 +33,9 @@ float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int typ
|
||||
}
|
||||
return m / 3.0f;
|
||||
}
|
||||
case FilterModel::LadderLP:
|
||||
case FilterModel::Diode:
|
||||
case FilterModel::Count:
|
||||
default:
|
||||
return (denom > 1e-6f) ? 1.0f / denom : 0.0f;
|
||||
}
|
||||
@@ -37,7 +43,8 @@ float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int typ
|
||||
|
||||
void FilterDisplay::setParams (int t, float c, float r, float d, int s)
|
||||
{
|
||||
if (type == t && cutoff == c && res == r && drive == d && slope == s)
|
||||
if (type == t && ! changed (cutoff, c) && ! changed (res, r)
|
||||
&& ! changed (drive, d) && slope == s)
|
||||
return;
|
||||
type = t; cutoff = c; res = r; drive = d; slope = s;
|
||||
repaint();
|
||||
|
||||
+75
-7
@@ -3,6 +3,17 @@
|
||||
namespace serum
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
// One shared look-and-feel for every knob. It outlives every Knob because
|
||||
// it is a function-local static and the GUI owns the knobs.
|
||||
KnobLookAndFeel& knobStyle()
|
||||
{
|
||||
static KnobLookAndFeel style;
|
||||
return style;
|
||||
}
|
||||
}
|
||||
|
||||
Knob::Knob (const juce::String& name, std::function<juce::String (float)> fmt)
|
||||
: formatter (std::move (fmt))
|
||||
{
|
||||
@@ -12,6 +23,10 @@ Knob::Knob (const juce::String& name, std::function<juce::String (float)> fmt)
|
||||
setDoubleClickReturnValue (true, 0.5);
|
||||
setName (name);
|
||||
setComponentID (name);
|
||||
|
||||
// Draw the dial and the name strip through the theme, not through the
|
||||
// shared default look-and-feel.
|
||||
setLookAndFeel (&knobStyle());
|
||||
}
|
||||
|
||||
juce::String Knob::getTextFromValue (double value)
|
||||
@@ -21,6 +36,32 @@ juce::String Knob::getTextFromValue (double value)
|
||||
return formatPercent ((float) value);
|
||||
}
|
||||
|
||||
// The dial takes the top of the footprint and the name takes a strip at the
|
||||
// bottom, so the name is always visible without a tooltip hover.
|
||||
void Knob::resized()
|
||||
{
|
||||
juce::Slider::resized();
|
||||
textArea = getLocalBounds().removeFromBottom (
|
||||
juce::jmin (KnobLookAndFeel::labelHeight, getHeight() / 2));
|
||||
}
|
||||
|
||||
void Knob::paint (juce::Graphics& g)
|
||||
{
|
||||
juce::Slider::paint (g);
|
||||
|
||||
if (textArea.isEmpty())
|
||||
return;
|
||||
|
||||
// Dim while disabled, bright while hovered, otherwise the normal label
|
||||
// colour, so the three states are distinguishable without colour alone.
|
||||
const auto colour = isEnabled() ? (isMouseOverOrDragging() ? theme::text : theme::textDim)
|
||||
: theme::outline;
|
||||
|
||||
g.setColour (colour);
|
||||
g.setFont (juce::Font (11.0f));
|
||||
g.drawText (getName(), textArea, juce::Justification::centred, false);
|
||||
}
|
||||
|
||||
void Knob::mouseDown (const juce::MouseEvent& e)
|
||||
{
|
||||
juce::Slider::mouseDown (e);
|
||||
@@ -56,6 +97,24 @@ void Knob::showValuePopup (const juce::MouseEvent& e)
|
||||
if (topLevel == nullptr)
|
||||
return;
|
||||
|
||||
constexpr int baseW = 84;
|
||||
constexpr int baseH = 24;
|
||||
constexpr int gap = 10;
|
||||
|
||||
// The interface scale is applied to a parent component, not to this slider.
|
||||
// Measure it from the local-space distance between two neighbouring points,
|
||||
// so a scaled interface does not leave the readout at a fixed size.
|
||||
float scale = 1.0f;
|
||||
if (auto* parent = getParentComponent())
|
||||
{
|
||||
const auto origin = getLocalPoint (parent, juce::Point<int> (0, 0)).toFloat();
|
||||
const auto unitX = getLocalPoint (parent, juce::Point<int> (1, 0)).toFloat();
|
||||
scale = origin.getDistanceFrom (unitX);
|
||||
}
|
||||
scale = juce::jlimit (0.5f, 2.5f, scale);
|
||||
const int w = juce::roundToInt (baseW * scale);
|
||||
const int h = juce::roundToInt (baseH * scale);
|
||||
|
||||
if (valuePopup == nullptr)
|
||||
{
|
||||
valuePopup = std::make_unique<juce::Label>();
|
||||
@@ -63,19 +122,20 @@ void Knob::showValuePopup (const juce::MouseEvent& e)
|
||||
valuePopup->setInterceptsMouseClicks (false, false);
|
||||
valuePopup->setColour (juce::Label::backgroundColourId, juce::Colours::transparentBlack);
|
||||
valuePopup->setColour (juce::Label::textColourId, theme::text);
|
||||
valuePopup->setFont (juce::Font (14.0f, juce::Font::bold));
|
||||
valuePopup->setJustificationType (juce::Justification::centred);
|
||||
topLevel->addAndMakeVisible (valuePopup.get());
|
||||
}
|
||||
|
||||
valuePopup->setFont (juce::Font (14.0f * scale, juce::Font::bold));
|
||||
valuePopup->setText (getTextFromValue (getValue()), juce::dontSendNotification);
|
||||
|
||||
constexpr int w = 84;
|
||||
constexpr int h = 24;
|
||||
constexpr int gap = 10;
|
||||
|
||||
const auto cursor = topLevel->getLocalPoint (nullptr, e.getScreenPosition());
|
||||
valuePopup->setBounds (cursor.getX() - w / 2, cursor.getY() - h - gap, w, h);
|
||||
// Keep the readout inside the window so a knob near an edge still shows it.
|
||||
const auto popupPos = topLevel->getLocalPoint (nullptr, e.getScreenPosition());
|
||||
valuePopup->setBounds (juce::jlimit (0, juce::jmax (0, topLevel->getWidth() - w),
|
||||
popupPos.getX() - w / 2),
|
||||
juce::jlimit (0, juce::jmax (0, topLevel->getHeight() - h),
|
||||
popupPos.getY() - h - gap),
|
||||
w, h);
|
||||
}
|
||||
|
||||
void Knob::hideValuePopup()
|
||||
@@ -83,4 +143,12 @@ void Knob::hideValuePopup()
|
||||
valuePopup.reset();
|
||||
}
|
||||
|
||||
// A section switch can hide the view holding this knob mid-drag. The popup is a
|
||||
// sibling of that view, so it has to be taken down here or it stays on screen.
|
||||
void Knob::visibilityChanged()
|
||||
{
|
||||
if (! isVisible())
|
||||
hideValuePopup();
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
+14
-2
@@ -3,13 +3,16 @@
|
||||
#include <JuceHeader.h>
|
||||
#include <memory>
|
||||
#include "../Resources.h"
|
||||
#include "KnobLookAndFeel.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Rotary knob — a juce::Slider styled by SerumLookAndFeel. The value readout is
|
||||
// formatted by a user-provided function via getTextFromValue().
|
||||
// Rotary knob — a juce::Slider drawn by KnobLookAndFeel. The knob paints its
|
||||
// own name below the dial, so no control is a bare arc whose only hint is a
|
||||
// tooltip. The value readout is formatted by a user-provided function via
|
||||
// getTextFromValue().
|
||||
//
|
||||
// While the knob is being dragged it also shows a small floating label near the
|
||||
// mouse cursor with the live (formatted) value, giving immediate feedback
|
||||
@@ -23,8 +26,16 @@ public:
|
||||
|
||||
void setFormatter (std::function<juce::String (float)> f) { formatter = std::move (f); }
|
||||
|
||||
// Value a double click returns to. Call it with the parameter's own default
|
||||
// so the gesture restores the real default, never a hardcoded 0.5.
|
||||
void setDefaultValue (double v) { setDoubleClickReturnValue (true, v); }
|
||||
|
||||
juce::String getTextFromValue (double value) override;
|
||||
|
||||
void paint (juce::Graphics& g) override;
|
||||
void resized() override;
|
||||
void visibilityChanged() override;
|
||||
|
||||
void mouseDown (const juce::MouseEvent& e) override;
|
||||
void mouseDrag (const juce::MouseEvent& e) override;
|
||||
void mouseUp (const juce::MouseEvent& e) override;
|
||||
@@ -36,6 +47,7 @@ private:
|
||||
|
||||
std::function<juce::String (float)> formatter;
|
||||
std::unique_ptr<juce::Label> valuePopup;
|
||||
juce::Rectangle<int> textArea; // name strip under the dial
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "KnobLookAndFeel.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void KnobLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height,
|
||||
float sliderPos, float rotaryStartAngle, float rotaryEndAngle,
|
||||
juce::Slider& slider)
|
||||
{
|
||||
const auto dial = juce::Rectangle<float> ((float) x, (float) y, (float) width, (float) height);
|
||||
if (dial.isEmpty())
|
||||
return;
|
||||
|
||||
const float radius = juce::jmin (dial.getWidth(), dial.getHeight()) * 0.5f - 2.0f;
|
||||
if (radius <= 1.0f)
|
||||
return;
|
||||
|
||||
const float lineW = juce::jlimit (1.5f, 5.0f, radius * 0.22f);
|
||||
const float arcR = radius - lineW * 0.5f;
|
||||
const float centreX = dial.getCentreX();
|
||||
const float centreY = dial.getCentreY();
|
||||
const float toAngle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle);
|
||||
const bool live = slider.isEnabled();
|
||||
|
||||
juce::Path track;
|
||||
track.addCentredArc (centreX, centreY, arcR, arcR, 0.0f, rotaryStartAngle, rotaryEndAngle, true);
|
||||
g.setColour (theme::outline);
|
||||
g.strokePath (track, juce::PathStrokeType (lineW, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
|
||||
|
||||
if (live)
|
||||
{
|
||||
juce::Path value;
|
||||
value.addCentredArc (centreX, centreY, arcR, arcR, 0.0f, rotaryStartAngle, toAngle, true);
|
||||
g.setColour (theme::accent.withAlpha (slider.isMouseOverOrDragging() ? 1.0f : 0.75f));
|
||||
g.strokePath (value, juce::PathStrokeType (lineW, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
|
||||
}
|
||||
|
||||
const float pointer = radius * 0.62f;
|
||||
g.setColour (live ? theme::text : theme::textDim);
|
||||
g.drawLine (centreX + std::sin (toAngle) * pointer * 0.35f,
|
||||
centreY - std::cos (toAngle) * pointer * 0.35f,
|
||||
centreX + std::sin (toAngle) * pointer,
|
||||
centreY - std::cos (toAngle) * pointer,
|
||||
juce::jmax (1.5f, lineW * 0.5f));
|
||||
}
|
||||
|
||||
} // namespace serum
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Look-and-feel for Knob.
|
||||
//
|
||||
// Two jobs:
|
||||
//
|
||||
// 1. Draw the rotary with theme:: colours. The shared default look-and-feel
|
||||
// paints it with its own palette, which ignores the rest of the editor.
|
||||
//
|
||||
// 2. Leave room for the knob's name. juce::LookAndFeel_V4 reduces the rotary
|
||||
// bounds by a fixed ten pixels on each side, so a 76 pixel knob loses
|
||||
// 20 pixels of dial and a smaller knob loses most of it. This version
|
||||
// keeps the dial whole inside its footprint and reserves a text strip
|
||||
// under it, which is the strip Knob paints its name into.
|
||||
// ===========================================================================
|
||||
class KnobLookAndFeel : public juce::LookAndFeel_V4
|
||||
{
|
||||
public:
|
||||
// Height of the name strip reserved under the dial.
|
||||
static constexpr int labelHeight = 16;
|
||||
|
||||
void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height,
|
||||
float sliderPos, float rotaryStartAngle, float rotaryEndAngle,
|
||||
juce::Slider& slider) override;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
+29
-33
@@ -1,52 +1,28 @@
|
||||
#include "LFODisplay.h"
|
||||
#include "../ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void LFODisplay::setShapeData (const std::vector<float>& data, int s)
|
||||
{
|
||||
const int newSteps = juce::jlimit (2, 64, s);
|
||||
const size_t dataSize = std::min (data.size(), size_t (64));
|
||||
if (steps == newSteps && shapeData.size() == 64
|
||||
const int newSteps = juce::jlimit (2, LFO::kShapePoints, s);
|
||||
const auto dataSize = (std::ptrdiff_t) std::min (data.size(), size_t (LFO::kShapePoints));
|
||||
if (steps == newSteps && (int) shapeData.size() == LFO::kShapePoints
|
||||
&& std::equal (data.begin(), data.begin() + dataSize, shapeData.begin())
|
||||
&& std::all_of (shapeData.begin() + dataSize, shapeData.end(), [] (float v) { return v == 0.0f; }))
|
||||
&& std::all_of (shapeData.begin() + dataSize, shapeData.end(), [] (float v) { return exactly (v, 0.0f); }))
|
||||
return;
|
||||
|
||||
steps = newSteps;
|
||||
shapeData.assign (data.begin(), data.begin() + dataSize);
|
||||
shapeData.resize (64, 0.0f);
|
||||
shapeData.resize ((size_t) LFO::kShapePoints, 0.0f);
|
||||
repaint();
|
||||
}
|
||||
|
||||
float LFODisplay::valueAt (float phase) const noexcept
|
||||
{
|
||||
switch ((LfoShape) shape)
|
||||
{
|
||||
case LfoShape::Sine: return std::sin (phase * 6.2831853f);
|
||||
case LfoShape::Triangle: return 1.0f - 4.0f * std::abs (phase - 0.5f);
|
||||
case LfoShape::Saw: return 2.0f * phase - 1.0f;
|
||||
case LfoShape::Square: return (phase < 0.5f) ? 1.0f : -1.0f;
|
||||
case LfoShape::SampleHold:
|
||||
{
|
||||
const int step = juce::jlimit (0, 15, (int) (phase * 16.0f));
|
||||
return ((step * 2654435761u) & 0xffffu) / 32768.0f - 1.0f;
|
||||
}
|
||||
case LfoShape::StepSeq:
|
||||
{
|
||||
const int idx = juce::jlimit (0, steps - 1, (int) (phase * steps));
|
||||
return shapeData.empty() ? 0.0f : shapeData[(size_t) idx];
|
||||
}
|
||||
case LfoShape::Freehand:
|
||||
{
|
||||
if (shapeData.empty()) return 0.0f;
|
||||
const float pos = phase * (float) (steps - 1);
|
||||
const int i0 = (int) pos;
|
||||
const int i1 = juce::jmin (i0 + 1, steps - 1);
|
||||
const float frac = pos - (float) i0;
|
||||
return shapeData[(size_t) i0] * (1.0f - frac) + shapeData[(size_t) i1] * frac;
|
||||
}
|
||||
default: return 0.0f;
|
||||
}
|
||||
return lfoShapeValue (phase, (LfoShape) shape, steps,
|
||||
shapeData.data(), (int) shapeData.size(), kSampleHoldLevel);
|
||||
}
|
||||
|
||||
void LFODisplay::paint (juce::Graphics& g)
|
||||
@@ -75,6 +51,26 @@ void LFODisplay::paint (juce::Graphics& g)
|
||||
}
|
||||
}
|
||||
|
||||
// Sample and hold is stateful in the audio path, so the preview draws the
|
||||
// held level as a flat schematic with its hold steps marked, and labels it,
|
||||
// rather than inventing a sequence the synth will never play.
|
||||
if (shape == (int) LfoShape::SampleHold)
|
||||
{
|
||||
const float stepW = (b.getWidth() - 8.0f) / (float) steps;
|
||||
g.setColour (theme::outline);
|
||||
for (int i = 1; i < steps; ++i)
|
||||
{
|
||||
const float x = b.getX() + 4.0f + stepW * (float) i;
|
||||
g.drawLine (x, midY - b.getHeight() * 0.28f, x, midY + b.getHeight() * 0.28f, 1.0f);
|
||||
}
|
||||
|
||||
g.setColour (theme::textDim);
|
||||
g.setFont (10.0f);
|
||||
g.drawText ("S&H schematic",
|
||||
juce::Rectangle<float> (b.getX(), b.getY(), b.getWidth(), 14.0f).toNearestInt(),
|
||||
juce::Justification::centred, false);
|
||||
}
|
||||
|
||||
juce::Path path;
|
||||
path.startNewSubPath (b.getX() + 4.0f, midY);
|
||||
const int n = 256;
|
||||
@@ -99,7 +95,7 @@ void LFODisplay::editAt (float x, float y)
|
||||
const float v = juce::jlimit (-1.0f, 1.0f, (midY - y) / (b.getHeight() * 0.42f));
|
||||
|
||||
if (shapeData.empty())
|
||||
shapeData.assign (64, 0.0f);
|
||||
shapeData.assign ((size_t) LFO::kShapePoints, 0.0f);
|
||||
|
||||
if (shape == (int) LfoShape::StepSeq)
|
||||
{
|
||||
|
||||
+14
-1
@@ -3,12 +3,17 @@
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
#include "../Params.h"
|
||||
#include "../LFO.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// LFO shape view. Step-sequencer and freehand shapes are click/drag editable.
|
||||
// The preview evaluates the same lfoShapeValue() function as the audio path, so
|
||||
// the drawn shape cannot drift from the sound. Sample and hold has no phase
|
||||
// function (the audio level is stateful), so its preview is a labelled
|
||||
// schematic rather than a fabricated sequence.
|
||||
// ===========================================================================
|
||||
class LFODisplay : public juce::Component
|
||||
{
|
||||
@@ -21,13 +26,21 @@ public:
|
||||
void mouseDown (const juce::MouseEvent& e) override;
|
||||
void mouseDrag (const juce::MouseEvent& e) override;
|
||||
|
||||
// Level the sample-and-hold preview draws. The audio LFO holds a level that
|
||||
// the display cannot observe on the message thread, so the preview shows a
|
||||
// flat schematic line at this value instead of inventing a sequence.
|
||||
static constexpr float kSampleHoldLevel = 0.0f;
|
||||
|
||||
// Value of the current shape at `phase` (0..1); the audio path calls the
|
||||
// same lfoShapeValue() with its live sample-and-hold level.
|
||||
float valueAt (float phase) const noexcept;
|
||||
|
||||
private:
|
||||
int shape = 0;
|
||||
std::vector<float> shapeData;
|
||||
int steps = 16;
|
||||
std::function<void (const std::vector<float>&, int)> onEdited;
|
||||
|
||||
float valueAt (float phase) const noexcept;
|
||||
void editAt (float x, float y);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../Resources.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
|
||||
// ===========================================================================
|
||||
// Dark vector look-and-feel. Rotary and linear sliders are drawn with juce::Path
|
||||
// (arcs + indicator + thumb) so they stay crisp at every UI scale.
|
||||
// ===========================================================================
|
||||
class SerumLookAndFeel : public juce::LookAndFeel_V4
|
||||
{
|
||||
public:
|
||||
SerumLookAndFeel()
|
||||
{
|
||||
setColour (juce::Slider::backgroundColourId, theme::panel);
|
||||
setColour (juce::Slider::thumbColourId, theme::accent);
|
||||
setColour (juce::Slider::trackColourId, theme::outline);
|
||||
setColour (juce::Slider::rotarySliderFillColourId, theme::accent);
|
||||
setColour (juce::Slider::rotarySliderOutlineColourId, theme::panelRaised);
|
||||
setColour (juce::Slider::textBoxTextColourId, theme::text);
|
||||
setColour (juce::Slider::textBoxBackgroundColourId, theme::panel);
|
||||
setColour (juce::Slider::textBoxHighlightColourId, theme::accent);
|
||||
setColour (juce::Slider::textBoxOutlineColourId, theme::outline);
|
||||
|
||||
setColour (juce::ComboBox::backgroundColourId, theme::panelRaised);
|
||||
setColour (juce::ComboBox::textColourId, theme::text);
|
||||
setColour (juce::ComboBox::arrowColourId, theme::textDim);
|
||||
setColour (juce::ComboBox::outlineColourId, theme::outline);
|
||||
setColour (juce::ComboBox::buttonColourId, theme::panelRaised);
|
||||
setColour (juce::ComboBox::focusedOutlineColourId, theme::accent);
|
||||
setColour (juce::PopupMenu::backgroundColourId, theme::panelRaised);
|
||||
setColour (juce::PopupMenu::textColourId, theme::text);
|
||||
setColour (juce::PopupMenu::highlightedBackgroundColourId, theme::accent);
|
||||
setColour (juce::PopupMenu::highlightedTextColourId, juce::Colours::black);
|
||||
|
||||
setColour (juce::TextButton::buttonColourId, theme::panelRaised);
|
||||
setColour (juce::TextButton::buttonOnColourId, theme::accent);
|
||||
setColour (juce::TextButton::textColourOffId, theme::textDim);
|
||||
setColour (juce::TextButton::textColourOnId, juce::Colours::black);
|
||||
|
||||
setColour (juce::TextEditor::backgroundColourId, theme::panel);
|
||||
setColour (juce::TextEditor::textColourId, theme::text);
|
||||
setColour (juce::TextEditor::outlineColourId, theme::outline);
|
||||
setColour (juce::TextEditor::focusedOutlineColourId, theme::accent);
|
||||
}
|
||||
|
||||
void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height,
|
||||
float sliderPos, float, float, juce::Slider& slider) override
|
||||
{
|
||||
const int labelH = 18;
|
||||
const int knobH = height - labelH;
|
||||
const int knobSize = juce::jmin (width, knobH) - 6;
|
||||
const int cx = x + width / 2;
|
||||
const int cy = y + knobH / 2;
|
||||
const float radius = knobSize * 0.5f;
|
||||
|
||||
const juce::Rectangle<float> area ((float) cx - radius, (float) cy - radius,
|
||||
radius * 2.0f, radius * 2.0f);
|
||||
|
||||
// Body.
|
||||
g.setColour (theme::panelRaised);
|
||||
g.fillEllipse (area);
|
||||
g.setColour (theme::outline);
|
||||
g.drawEllipse (area, 1.0f);
|
||||
|
||||
const float startAngle = juce::MathConstants<float>::pi * 1.25f; // 225 deg
|
||||
const float endAngle = juce::MathConstants<float>::pi * -0.25f;
|
||||
const float valueAngle = startAngle + sliderPos * (endAngle - startAngle);
|
||||
|
||||
// Track arc.
|
||||
juce::Path track;
|
||||
track.addCentredArc ((float) cx, (float) cy, radius - 4.0f, radius - 4.0f,
|
||||
0.0f, startAngle, endAngle, true);
|
||||
g.setColour (theme::outline);
|
||||
g.strokePath (track, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved));
|
||||
|
||||
// Value arc.
|
||||
juce::Path valueArc;
|
||||
valueArc.addCentredArc ((float) cx, (float) cy, radius - 4.0f, radius - 4.0f,
|
||||
0.0f, startAngle, valueAngle, true);
|
||||
g.setColour (theme::accent);
|
||||
g.strokePath (valueArc, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved));
|
||||
|
||||
// Indicator.
|
||||
const float indX = (float) cx + (radius - 9.0f) * std::cos (valueAngle);
|
||||
const float indY = (float) cy + (radius - 9.0f) * std::sin (valueAngle);
|
||||
g.setColour (theme::text);
|
||||
g.drawLine ((float) cx, (float) cy, indX, indY, 2.0f);
|
||||
g.setColour (theme::text);
|
||||
g.fillEllipse (indX - 2.0f, indY - 2.0f, 4.0f, 4.0f);
|
||||
|
||||
// Value readout.
|
||||
g.setColour (theme::text);
|
||||
g.setFont (juce::Font (10.0f, juce::Font::bold));
|
||||
g.drawText (slider.getTextFromValue (slider.getValue()),
|
||||
juce::Rectangle<int> (x, y, width, knobH - (int) radius + 8),
|
||||
juce::Justification::centred, false);
|
||||
|
||||
// Name label.
|
||||
g.setColour (theme::textDim);
|
||||
g.setFont (juce::Font (11.0f));
|
||||
g.drawText (slider.getName(), juce::Rectangle<int> (x, y + knobH, width, labelH),
|
||||
juce::Justification::centred, false);
|
||||
}
|
||||
|
||||
void drawLinearSlider (juce::Graphics& g, int x, int y, int width, int height,
|
||||
float sliderPos, float, float, juce::Slider::SliderStyle, juce::Slider& slider) override
|
||||
{
|
||||
const bool vertical = height > width;
|
||||
juce::Rectangle<int> track;
|
||||
|
||||
if (vertical)
|
||||
{
|
||||
track = juce::Rectangle<int> (x + width / 2 - 3, y + 8, 6, height - 16);
|
||||
}
|
||||
else
|
||||
{
|
||||
track = juce::Rectangle<int> (x + 8, y + height / 2 - 3, width - 16, 6);
|
||||
}
|
||||
|
||||
g.setColour (theme::outline);
|
||||
g.fillRoundedRectangle (track.toFloat(), 3.0f);
|
||||
|
||||
juce::Rectangle<float> fill;
|
||||
if (vertical)
|
||||
{
|
||||
const float h = track.getHeight() * sliderPos;
|
||||
fill = juce::Rectangle<float> ((float) track.getX(), (float) track.getBottom() - h,
|
||||
(float) track.getWidth(), h);
|
||||
}
|
||||
else
|
||||
{
|
||||
fill = juce::Rectangle<float> ((float) track.getX(), (float) track.getY(),
|
||||
track.getWidth() * sliderPos, (float) track.getHeight());
|
||||
}
|
||||
g.setColour (theme::accent);
|
||||
g.fillRoundedRectangle (fill, 3.0f);
|
||||
|
||||
// Thumb.
|
||||
const float thumbC = vertical ? (track.getBottom() - track.getHeight() * sliderPos)
|
||||
: (track.getX() + track.getWidth() * sliderPos);
|
||||
juce::Point<float> thumb (vertical ? (float) track.getCentreX() : thumbC,
|
||||
vertical ? thumbC : (float) track.getCentreY());
|
||||
g.setColour (theme::text);
|
||||
g.fillEllipse (thumb.x - 6.0f, thumb.y - 6.0f, 12.0f, 12.0f);
|
||||
|
||||
g.setColour (theme::textDim);
|
||||
g.setFont (juce::Font (10.0f));
|
||||
g.drawText (slider.getName(), x, y, width, height, juce::Justification::bottomLeft, false);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace serum
|
||||
{
|
||||
|
||||
Slider::Slider (const juce::String& name, std::function<juce::String (float)> formatter)
|
||||
: Knob (name, std::move (formatter))
|
||||
Slider::Slider (const juce::String& name, std::function<juce::String (float)> fmt)
|
||||
: Knob (name, std::move (fmt))
|
||||
{
|
||||
setSliderStyle (juce::Slider::LinearHorizontal);
|
||||
setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0);
|
||||
|
||||
@@ -33,21 +33,29 @@ void ToggleButton::paint (juce::Graphics& g)
|
||||
const float ledSize = juce::jmin (bounds.getHeight() - 16.0f, 16.0f);
|
||||
const juce::Rectangle<float> led ((bounds.getWidth() - ledSize) * 0.5f, 4.0f, ledSize, ledSize);
|
||||
|
||||
const bool live = isEnabled();
|
||||
const bool hovered = live && isMouseOverOrDragging();
|
||||
|
||||
if (state)
|
||||
{
|
||||
g.setColour (onColour.withAlpha (0.35f));
|
||||
// Hover brightens the halo, disabled dims it, so the three states are
|
||||
// distinguishable without relying on colour alone.
|
||||
const float halo = live ? (hovered ? 0.55f : 0.35f) : 0.15f;
|
||||
g.setColour (onColour.withAlpha (halo));
|
||||
g.fillEllipse (led.expanded (6.0f));
|
||||
g.setColour (onColour);
|
||||
g.setColour (live ? onColour : onColour.withAlpha (0.4f));
|
||||
}
|
||||
else
|
||||
{
|
||||
g.setColour (theme::outline);
|
||||
g.setColour (hovered ? theme::textDim : theme::outline);
|
||||
}
|
||||
g.fillEllipse (led);
|
||||
g.setColour (state ? juce::Colours::black : theme::textDim);
|
||||
g.drawEllipse (led, 1.0f);
|
||||
g.drawEllipse (led, live ? (hovered ? 1.6f : 1.0f) : 0.8f);
|
||||
|
||||
g.setColour (state ? theme::text : theme::textDim);
|
||||
const auto labelColour = ! live ? theme::outline
|
||||
: (state ? theme::text : (hovered ? theme::text : theme::textDim));
|
||||
g.setColour (labelColour);
|
||||
g.setFont (juce::Font (11.0f));
|
||||
g.drawText (label, 0, (int) (led.getBottom() + 2), getWidth(), 14, juce::Justification::centred, false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "../ExactCompare.h"
|
||||
#include "../Resources.h"
|
||||
#include "../Wavetable.h"
|
||||
|
||||
@@ -15,7 +16,7 @@ class WaveformDisplay : public juce::Component
|
||||
public:
|
||||
void setWavetables (const WavetableLibrary* lib) { if (wtLib != lib) { wtLib = lib; repaint(); } }
|
||||
void setWaveIndex (int index) { if (wave != index) { wave = index; repaint(); } }
|
||||
void setFramePosition (float pos) { if (wtPos != pos) { wtPos = pos; repaint(); } }
|
||||
void setFramePosition (float pos) { if (changed (wtPos, pos)) { wtPos = pos; repaint(); } }
|
||||
void setEnabled (bool e) { if (enabled != e) { enabled = e; repaint(); } }
|
||||
|
||||
void paint (juce::Graphics& g) override;
|
||||
|
||||
+29
-16
@@ -1,4 +1,5 @@
|
||||
#include "LFO.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
@@ -40,7 +41,7 @@ void LFO::setParams (float rateNorm, bool s, float b, int shp, float ph,
|
||||
|
||||
// Only restart the delay/fade timing when those knobs actually change,
|
||||
// so repeated block-rate setParams calls don't keep resetting the LFO.
|
||||
if (delay != prevDelayParam || fade != prevFadeParam)
|
||||
if (changed (delay, prevDelayParam) || changed (fade, prevFadeParam))
|
||||
{
|
||||
delayCounter = delaySeconds * sr;
|
||||
fadeCounter = 0.0;
|
||||
@@ -62,39 +63,51 @@ void LFO::setShapeData (const std::vector<float>& data, int steps)
|
||||
? juce::jlimit (-1.0f, 1.0f, data[i]) : 0.0f;
|
||||
}
|
||||
|
||||
float LFO::shapeValue() noexcept
|
||||
float lfoShapeValue (float phase, LfoShape shape, int steps,
|
||||
const float* data, int dataSize, float sampleHoldValue) noexcept
|
||||
{
|
||||
const float p = getPhase();
|
||||
switch ((LfoShape) shape)
|
||||
const int numSteps = juce::jlimit (2, LFO::kShapePoints, steps);
|
||||
const int numData = data != nullptr ? juce::jlimit (0, LFO::kShapePoints, dataSize) : 0;
|
||||
|
||||
auto point = [data, numData] (int index) noexcept
|
||||
{
|
||||
return numData > 0 ? data[(size_t) juce::jlimit (0, numData - 1, index)] : 0.0f;
|
||||
};
|
||||
|
||||
switch (shape)
|
||||
{
|
||||
case LfoShape::Sine:
|
||||
return std::sin (p * juce::MathConstants<float>::twoPi);
|
||||
return std::sin (phase * juce::MathConstants<float>::twoPi);
|
||||
case LfoShape::Triangle:
|
||||
return 1.0f - 4.0f * std::abs (p - 0.5f);
|
||||
return 1.0f - 4.0f * std::abs (phase - 0.5f);
|
||||
case LfoShape::Saw:
|
||||
return 2.0f * p - 1.0f;
|
||||
return 2.0f * phase - 1.0f;
|
||||
case LfoShape::Square:
|
||||
return (p < 0.5f) ? 1.0f : -1.0f;
|
||||
return (phase < 0.5f) ? 1.0f : -1.0f;
|
||||
case LfoShape::SampleHold:
|
||||
return holdValue;
|
||||
return sampleHoldValue;
|
||||
case LfoShape::StepSeq:
|
||||
{
|
||||
const int idx = juce::jlimit (0, shapeSteps - 1, (int) (p * shapeSteps));
|
||||
return shapeBuffer[(size_t) idx];
|
||||
}
|
||||
return point (juce::jlimit (0, numSteps - 1, (int) (phase * (float) numSteps)));
|
||||
case LfoShape::Freehand:
|
||||
{
|
||||
const float pos = p * (float) (shapeSteps - 1);
|
||||
const float pos = phase * (float) (numSteps - 1);
|
||||
const int i0 = (int) pos;
|
||||
const int i1 = juce::jmin (i0 + 1, shapeSteps - 1);
|
||||
const int i1 = juce::jmin (i0 + 1, numSteps - 1);
|
||||
const float frac = pos - (float) i0;
|
||||
return shapeBuffer[(size_t) i0] * (1.0f - frac) + shapeBuffer[(size_t) i1] * frac;
|
||||
return point (i0) * (1.0f - frac) + point (i1) * frac;
|
||||
}
|
||||
case LfoShape::Count:
|
||||
default:
|
||||
return 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
float LFO::shapeValue() noexcept
|
||||
{
|
||||
return lfoShapeValue (getPhase(), (LfoShape) shape, shapeSteps,
|
||||
shapeBuffer.data(), (int) shapeBuffer.size(), holdValue);
|
||||
}
|
||||
|
||||
float LFO::process() noexcept
|
||||
{
|
||||
return advance (1);
|
||||
|
||||
@@ -62,4 +62,13 @@ private:
|
||||
float shapeValue() noexcept;
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Pure shape evaluation shared by the audio render (LFO::shapeValue) and the
|
||||
// GUI preview (LFODisplay::valueAt), so the drawn shape cannot drift from the
|
||||
// played one. `sampleHoldValue` supplies the current held level for
|
||||
// LfoShape::SampleHold, which is stateful in the audio path.
|
||||
// ===========================================================================
|
||||
float lfoShapeValue (float phase, LfoShape shape, int steps,
|
||||
const float* data, int dataSize, float sampleHoldValue) noexcept;
|
||||
|
||||
} // namespace serum
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "MacroControls.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
@@ -79,7 +80,7 @@ void MacroControls::fromValueTree (const juce::ValueTree& tree)
|
||||
continue;
|
||||
const double savedIndex = (double) mac.getProperty ("index", -1);
|
||||
if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumMacros
|
||||
|| std::floor (savedIndex) != savedIndex)
|
||||
|| ! isWholeNumber (savedIndex))
|
||||
continue;
|
||||
const int index = (int) savedIndex;
|
||||
for (const auto& asg : mac)
|
||||
|
||||
+13
-4
@@ -1,4 +1,5 @@
|
||||
#include "Oscillator.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
@@ -12,9 +13,8 @@ void Oscillator::reset()
|
||||
paramsValid = false;
|
||||
}
|
||||
|
||||
void Oscillator::noteOn (double freqHz, const OscParams& p, juce::uint32 seed)
|
||||
void Oscillator::noteOn (const OscParams& p, juce::uint32 seed)
|
||||
{
|
||||
jassert (freqHz > 0.0);
|
||||
rng.setSeed (seed);
|
||||
initializedUnison = 0;
|
||||
paramsValid = false;
|
||||
@@ -25,8 +25,9 @@ void Oscillator::setParams (const OscParams& p) noexcept
|
||||
{
|
||||
const int uni = juce::jlimit (1, kMaxUnison, p.unison);
|
||||
const bool countChanged = ! paramsValid || uni != activeUnison;
|
||||
const bool detuneChanged = countChanged || p.detune != cachedParams.detune;
|
||||
const bool panChanged = countChanged || p.pan != cachedParams.pan || p.spread != cachedParams.spread;
|
||||
const bool detuneChanged = countChanged || changed (p.detune, cachedParams.detune);
|
||||
const bool panChanged = countChanged || changed (p.pan, cachedParams.pan)
|
||||
|| changed (p.spread, cachedParams.spread);
|
||||
if (! detuneChanged && ! panChanged)
|
||||
return;
|
||||
|
||||
@@ -98,6 +99,7 @@ float Oscillator::warpPhase (float phase, const OscParams& p) const noexcept
|
||||
}
|
||||
case WarpMode::PWM: return phase;
|
||||
case WarpMode::Fold: return phase;
|
||||
case WarpMode::Count: return phase;
|
||||
default: return phase;
|
||||
}
|
||||
}
|
||||
@@ -114,6 +116,13 @@ float Oscillator::warpSample (float sample, const OscParams& p) const noexcept
|
||||
}
|
||||
case WarpMode::Fold:
|
||||
return std::sin (sample * (1.0f + amt * 5.0f) * 1.5707963267948966f);
|
||||
case WarpMode::None:
|
||||
case WarpMode::BendPlus:
|
||||
case WarpMode::BendMinus:
|
||||
case WarpMode::Sync:
|
||||
case WarpMode::Asym:
|
||||
case WarpMode::Mirror:
|
||||
case WarpMode::Count:
|
||||
default:
|
||||
return sample;
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ public:
|
||||
void reset();
|
||||
|
||||
// Initialise a fresh note's unison sub-voices: phase offsets, detune, pan and gain.
|
||||
void noteOn (double freqHz, const OscParams& p, juce::uint32 seed);
|
||||
void noteOn (const OscParams& p, juce::uint32 seed);
|
||||
void setParams (const OscParams& p) noexcept;
|
||||
|
||||
// Accumulate this oscillator's contribution into outL/outR.
|
||||
|
||||
@@ -24,6 +24,7 @@ juce::String modSourceName (ModSource s)
|
||||
case ModSource::Macro3: return "Macro 3";
|
||||
case ModSource::Macro4: return "Macro 4";
|
||||
case ModSource::Random: return "Random";
|
||||
case ModSource::Count: return {};
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
@@ -41,6 +42,7 @@ juce::String modSourceToString (ModSource s)
|
||||
case ModSource::Macro1: return "macro1"; case ModSource::Macro2: return "macro2";
|
||||
case ModSource::Macro3: return "macro3"; case ModSource::Macro4: return "macro4";
|
||||
case ModSource::Random: return "random";
|
||||
case ModSource::Count: return {};
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
@@ -88,6 +90,8 @@ juce::String modTargetName (ModTarget t)
|
||||
case ModTarget::Fx3Mix: return "FX 3 Mix"; case ModTarget::Fx4Mix: return "FX 4 Mix";
|
||||
case ModTarget::Fx5Mix: return "FX 5 Mix"; case ModTarget::Fx6Mix: return "FX 6 Mix";
|
||||
case ModTarget::Fx7Mix: return "FX 7 Mix"; case ModTarget::Fx8Mix: return "FX 8 Mix";
|
||||
case ModTarget::None: return {};
|
||||
case ModTarget::Count: return {};
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
@@ -127,6 +131,8 @@ juce::String modTargetParamId (ModTarget t)
|
||||
case ModTarget::Fx3Mix: return ids::fx3Mix; case ModTarget::Fx4Mix: return ids::fx4Mix;
|
||||
case ModTarget::Fx5Mix: return ids::fx5Mix; case ModTarget::Fx6Mix: return ids::fx6Mix;
|
||||
case ModTarget::Fx7Mix: return ids::fx7Mix; case ModTarget::Fx8Mix: return ids::fx8Mix;
|
||||
case ModTarget::None: return {};
|
||||
case ModTarget::Count: return {};
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
+315
-136
@@ -19,7 +19,6 @@ namespace
|
||||
constexpr int knobSmall = 44; // small knob width (env / LFO rows)
|
||||
constexpr int rowHeight = knobSize + padding; // vertical pitch of knob rows
|
||||
constexpr int panelSpacing = 10; // gap between adjacent panels
|
||||
constexpr int labelHeight = 16; // label strip height
|
||||
|
||||
// Derived from the visual style (panel title bar and control sizes) so the
|
||||
// content grid stays aligned across every tab.
|
||||
@@ -28,7 +27,12 @@ namespace
|
||||
constexpr int buttonHeight = 26; // text button height
|
||||
constexpr int toggleWidth = 40; // "On"/"Sync" toggle width
|
||||
constexpr int toggleHeight = 36; // toggle height (LED + visible label)
|
||||
constexpr int toggleWide = 60; // wider toggle for a word like "Bipolar"
|
||||
constexpr int headerHeight = toggleHeight; // top control row height
|
||||
constexpr int depthKnobHeight = 60; // matrix / macro depth knob height
|
||||
// The header row is centred on the depth knob, which is taller than a
|
||||
// toggle, so the row centre follows the knob and not headerHeight.
|
||||
constexpr int headerCentreY = titleHeight + depthKnobHeight / 2;
|
||||
constexpr int displayHeight = 66; // waveform / filter display height
|
||||
constexpr int envDisplayHeight = 56; // envelope display height
|
||||
constexpr int lfoDisplayHeight = 64; // LFO display height
|
||||
@@ -36,6 +40,24 @@ namespace
|
||||
constexpr int smallGap = 3; // horizontal spacing between small knobs
|
||||
constexpr int arrowWidth = 26; // FX up/down arrow button width
|
||||
constexpr int macroKnobSize = 120; // macro knob footprint
|
||||
|
||||
// Tab content starts below the top bar, so every view shares one origin.
|
||||
constexpr int tabBarHeight = 60;
|
||||
|
||||
// Panel grids. Each tab lays its panels out in a fixed number of equal
|
||||
// columns, so the column width lives here rather than beside the code
|
||||
// that happens to use it.
|
||||
constexpr int twoColW = 545; // two equal columns (OSC, FX)
|
||||
constexpr int threeColW = 360; // three equal columns (FILTER)
|
||||
constexpr int fourColW = 269; // four equal columns (MOD, MACRO)
|
||||
constexpr int fourColGap = 8; // MOD and MACRO pack their columns tighter
|
||||
|
||||
constexpr int oscPanelH = 308; // oscillator panel height
|
||||
constexpr int smallPanelH = 150; // sub, noise and FX slot panel height
|
||||
constexpr int filterPanelH = 300; // filter panel height
|
||||
constexpr int envPanelH = 154; // envelope panel height
|
||||
constexpr int lfoPanelH = 206; // LFO panel height
|
||||
constexpr int macroPanelH = 158; // macro panel height
|
||||
}
|
||||
|
||||
// Horizontal position of a grid column (0-based).
|
||||
@@ -85,8 +107,70 @@ namespace
|
||||
}
|
||||
|
||||
juce::String fmtUnison (float v) { return juce::String (1 + (int) std::llround (v * 15.0f)); }
|
||||
juce::String fmtSlope (float v) { const int s = (int) std::llround (v * 2.0f); return juce::String (s == 0 ? 6 : (s == 1 ? 12 : 24)) + " dB"; }
|
||||
juce::String fmtDepth (float v) { return juce::String (v, 2); }
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Display formatters. One per physical unit the editor shows. Each takes a
|
||||
// normalised 0..1 parameter and returns the value the DSP reads, with its
|
||||
// unit, so a readout and a tooltip can state the same range.
|
||||
// -----------------------------------------------------------------------
|
||||
juce::String fmtGainDb (float v) { return juce::String ((v - 0.5f) * 30.0f, 1) + " dB"; }
|
||||
juce::String fmtGainDb12 (float v) { return juce::String ((v - 0.5f) * 24.0f, 1) + " dB"; }
|
||||
juce::String fmtCompThreshold (float v) { return juce::String (-40.0f + v * 40.0f, 1) + " dB"; }
|
||||
juce::String fmtCompRatio (float v) { return juce::String (2.0f + v * 18.0f, 1) + ":1"; }
|
||||
juce::String fmtCompAttack (float v) { return juce::String (2.0f + v * 98.0f, 0) + " ms"; }
|
||||
juce::String fmtCompRelease (float v) { return juce::String (30.0f + v * 470.0f, 0) + " ms"; }
|
||||
juce::String fmtDelayTime (float v) { return juce::String (maps::delayToMs (v), 0) + " ms"; }
|
||||
juce::String fmtLfoRate (float v) { return juce::String (maps::rateToHz (v), 2) + " Hz"; }
|
||||
juce::String fmtLfoFade (float v) { return juce::String (v * 8.0f, 2) + " s"; }
|
||||
juce::String fmtLfoDelay (float v) { return juce::String (v * 4.0f, 2) + " s"; }
|
||||
|
||||
// Beat division, as the period the LFO holds for one cycle.
|
||||
juce::String fmtLfoBeat (float v)
|
||||
{
|
||||
static const char* const names[16] = {
|
||||
"1/32", "1/16", "1/8", "1/6", "1/4", "1/3", "1/2", "1",
|
||||
"1.5", "2", "3", "4", "6", "8", "12", "16"
|
||||
};
|
||||
const int idx = juce::jlimit (0, 15, (int) std::llround (v * 15.0f));
|
||||
return juce::String ("1 cycle = ") + names[idx] + " beat" + (idx == 7 ? "" : "s");
|
||||
}
|
||||
|
||||
// Phase and random phase are fractions of one cycle.
|
||||
juce::String fmtDegrees (float v) { return juce::String (juce::roundToInt (v * 360.0f)) + " deg"; }
|
||||
|
||||
// Filter output is a gain of 0 to 1.5, so 100 % is 1.5x, not unity.
|
||||
juce::String fmtFilterOut (float v) { return juce::String (juce::roundToInt (v * 150.0f)) + " %"; }
|
||||
|
||||
// Modulation depth spans -100 % to +100 %; a negative depth inverts.
|
||||
juce::String fmtModDepth (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + " %"; }
|
||||
|
||||
// Beat divisions, indexed the same way as maps::beatToMultiplier.
|
||||
juce::String formatFrequency (float hz)
|
||||
{
|
||||
if (hz >= 1000.0f)
|
||||
return juce::String (hz / 1000.0f, 1) + " kHz";
|
||||
return juce::String (hz, hz < 100.0f ? 1 : 0) + " Hz";
|
||||
}
|
||||
|
||||
juce::String fmtChorusRate (float v) { return formatFrequency (0.1f + v * 4.9f); }
|
||||
juce::String fmtFlangerRate (float v) { return formatFrequency (0.05f + v * 2.0f); }
|
||||
juce::String fmtPhaserRate (float v) { return formatFrequency (0.05f + v * 2.0f); }
|
||||
juce::String fmtDistTone (float v) { return formatFrequency (800.0f + v * 15200.0f); }
|
||||
juce::String fmtEqMidFreq (float v) { return formatFrequency (200.0f + v * 4800.0f); }
|
||||
|
||||
// Chorus and flanger depth is a delay excursion in milliseconds.
|
||||
juce::String fmtChorusDepth (float v) { return juce::String (0.5f + v * 5.5f, 2) + " ms"; }
|
||||
juce::String fmtFlangerDepth (float v) { return juce::String (0.3f + v * 4.7f, 2) + " ms"; }
|
||||
|
||||
juce::String fmtDistDrive (float v) { return juce::String (1.0f + v * 24.0f, 1) + "x"; }
|
||||
juce::String fmtDistOutput (float v) { return juce::String (0.4f + v * 1.2f, 2) + "x"; }
|
||||
juce::String fmtHyperOutput (float v) { return juce::String (0.5f + v, 2) + "x"; }
|
||||
juce::String fmtPhaserStages (float v)
|
||||
{
|
||||
return juce::String (juce::jlimit (2, 6, 2 + (int) (v * 4.0f + 0.5f))) + " stages";
|
||||
}
|
||||
juce::String fmtPingPong (float v) { return v > 0.5f ? "On" : "Off"; }
|
||||
juce::String fmtPredelayMs (float v) { return juce::String (v * 150.0f, 0) + " ms"; }
|
||||
|
||||
void placeKnobs (std::vector<Knob*>& knobs, int x, int y, int w, int h, int gap = layout::gap)
|
||||
{
|
||||
@@ -101,17 +185,61 @@ namespace
|
||||
constexpr auto& kFxP3 = paramIds::fxP3;
|
||||
constexpr auto& kFxP4 = paramIds::fxP4;
|
||||
|
||||
constexpr const char* kFxParamNames[(int) FxType::Count][4] = {
|
||||
{ "P1", "P2", "P3", "P4" },
|
||||
{ "Intensity", "Low Amount", "High Amount", "Output" },
|
||||
{ "Rate", "Depth", "Width", "Unused" },
|
||||
{ "Rate", "Depth", "Feedback", "Unused" },
|
||||
{ "Rate", "Depth", "Feedback", "Stages" },
|
||||
{ "Drive", "Shape", "Tone", "Output" },
|
||||
{ "Low Gain", "Mid Gain", "High Gain", "Mid Freq" },
|
||||
{ "Threshold", "Ratio", "Attack", "Release" },
|
||||
{ "Time", "Feedback", "Damping", "Ping-Pong" },
|
||||
{ "Size", "Damping", "Width", "Predelay" }
|
||||
// How one FX slot parameter is presented: the name under the knob, the text
|
||||
// that converts its normalised value to physical units, and the tooltip that
|
||||
// states what it does and its range.
|
||||
struct FxParamDisplay
|
||||
{
|
||||
const char* name;
|
||||
const char* tooltip;
|
||||
std::function<juce::String (float)> formatter;
|
||||
};
|
||||
|
||||
// Indexed by FxType then by parameter slot. Names and ranges come from the
|
||||
// effect that reads the parameter in Source/EffectUnits/.
|
||||
const FxParamDisplay kFxParamDisplay[(int) FxType::Count][4] = {
|
||||
// Off: the rack skips the slot entirely, so say so rather than showing
|
||||
// four knobs that do nothing.
|
||||
{ { "Unused", "Effect is off, this slot does nothing", formatPercent },
|
||||
{ "Unused", "Effect is off, this slot does nothing", formatPercent },
|
||||
{ "Unused", "Effect is off, this slot does nothing", formatPercent },
|
||||
{ "Unused", "Effect is off, this slot does nothing", formatPercent } },
|
||||
{ { "Intensity", "How far the multiband up and down compression goes, 0 to 100 %", formatPercent },
|
||||
{ "Low Amount", "How much of the low band is fed back in, 40 to 100 %", formatPercent },
|
||||
{ "High Amount", "How much of the high band is fed back in, 40 to 100 %", formatPercent },
|
||||
{ "Output", "Output gain, 0.5 to 1.5x", fmtHyperOutput } },
|
||||
{ { "Rate", "Modulation speed, 0.1 Hz to 5 Hz", fmtChorusRate },
|
||||
{ "Depth", "Pitch swing, 0.5 ms to 6 ms of delay", fmtChorusDepth },
|
||||
{ "Width", "Stereo width of the modulated copy, 0 to 100 %", formatPercent },
|
||||
{ "Unused", "Not used by this effect", formatPercent } },
|
||||
{ { "Rate", "Modulation speed, 0.05 Hz to 2.05 Hz", fmtFlangerRate },
|
||||
{ "Depth", "Pitch swing, 0.3 ms to 5 ms of delay", fmtFlangerDepth },
|
||||
{ "Feedback", "How much output is fed back in, 0 to 90 %", formatPercent },
|
||||
{ "Unused", "Not used by this effect", formatPercent } },
|
||||
{ { "Rate", "Modulation speed, 0.05 Hz to 2.05 Hz", fmtPhaserRate },
|
||||
{ "Depth", "Sweep depth, 30 to 90 %", formatPercent },
|
||||
{ "Feedback", "How much output is fed back in, 0 to 70 %", formatPercent },
|
||||
{ "Stages", "Number of all-pass stages, 2 to 6", fmtPhaserStages } },
|
||||
{ { "Drive", "Input gain into the shaper, 1x to 25x", fmtDistDrive },
|
||||
{ "Shape", "Clipping shape. Low is soft, middle is hard, high folds.", formatPercent },
|
||||
{ "Tone", "Tone filter cutoff, 800 Hz to 16 kHz", fmtDistTone },
|
||||
{ "Output", "Output gain, 0.4x to 1.6x", fmtDistOutput } },
|
||||
{ { "Low Gain", "Low band gain, -15 dB to +15 dB", fmtGainDb },
|
||||
{ "Mid Gain", "Mid band gain, -12 dB to +12 dB", fmtGainDb12 },
|
||||
{ "High Gain", "High band gain, -15 dB to +15 dB", fmtGainDb },
|
||||
{ "Mid Freq", "Mid band centre frequency, 200 Hz to 5 kHz", fmtEqMidFreq } },
|
||||
{ { "Threshold", "Level where compression starts, -40 dB to 0 dB", fmtCompThreshold },
|
||||
{ "Ratio", "Input to output ratio above the threshold, 2:1 to 20:1", fmtCompRatio },
|
||||
{ "Attack", "How fast the compressor reacts, 2 ms to 100 ms", fmtCompAttack },
|
||||
{ "Release", "How fast the compressor lets go, 30 ms to 500 ms", fmtCompRelease } },
|
||||
{ { "Time", "Delay time, 1 ms to 2000 ms", fmtDelayTime },
|
||||
{ "Feedback", "How much output is fed back in, 0 to 92 %", formatPercent },
|
||||
{ "Damping", "How much the repeats lose their top end, 0 to 95 %", formatPercent },
|
||||
{ "Ping-Pong", "Bounces the repeats between left and right, on or off", fmtPingPong } },
|
||||
{ { "Size", "Reverb room size, 20 to 99 %", formatPercent },
|
||||
{ "Damping", "How much the tail loses its top end, 0 to 100 %", formatPercent },
|
||||
{ "Width", "Stereo width of the tail, 0 to 100 %", formatPercent },
|
||||
{ "Predelay", "Gap before the reverb starts, 0 ms to 150 ms", fmtPredelayMs } }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,9 +301,15 @@ Knob* PluginEditor::makeKnob (juce::Component* parent, const juce::String& name,
|
||||
auto* k = control.get();
|
||||
ownedControls.push_back (std::move (control));
|
||||
parent->addAndMakeVisible (k);
|
||||
if (paramId.isNotEmpty() && processor.parameters.getParameter (paramId) != nullptr)
|
||||
if (auto* param = paramId.isNotEmpty() ? processor.parameters.getParameter (paramId) : nullptr)
|
||||
{
|
||||
// A double click restores the parameter's own default. Without this a
|
||||
// double click writes the knob's built-in 0.5 and silently changes any
|
||||
// parameter whose default is not 0.5.
|
||||
k->setDefaultValue (param->convertFrom0to1 (param->getDefaultValue()));
|
||||
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
|
||||
processor.parameters, paramId, *k));
|
||||
}
|
||||
k->setTooltip (tooltip.isNotEmpty() ? tooltip : name);
|
||||
return k;
|
||||
}
|
||||
@@ -222,7 +356,7 @@ void PluginEditor::buildTopBar()
|
||||
presetCombo.onChange = [this] { processor.setCurrentProgram (presetCombo.getSelectedItemIndex()); };
|
||||
root.addAndMakeVisible (presetCombo);
|
||||
presetCombo.setBounds (200, 16, 220, 24);
|
||||
presetCombo.setTooltip ("Select a factory preset");
|
||||
presetCombo.setTooltip ("Loads a factory preset and replaces the current patch");
|
||||
|
||||
// UI scale.
|
||||
scaleCombo.addItemList ({ "75%", "100%", "125%", "150%", "200%" }, 1);
|
||||
@@ -230,25 +364,25 @@ void PluginEditor::buildTopBar()
|
||||
scaleCombo.onChange = [this] { processor.setUiScaleIndex (scaleCombo.getSelectedItemIndex()); };
|
||||
root.addAndMakeVisible (scaleCombo);
|
||||
scaleCombo.setBounds (980, 16, 70, 24);
|
||||
scaleCombo.setTooltip ("Interface scale (75% - 200%)");
|
||||
scaleCombo.setTooltip ("Interface scale, 75 % to 200 %");
|
||||
|
||||
// RAVE.
|
||||
raveButton.setLabel ("RAVE");
|
||||
raveButton.setLabel ("Rave");
|
||||
raveButton.setOnColour (theme::raveGlow);
|
||||
raveButton.setBounds (1062, 2, 50, 56);
|
||||
root.addAndMakeVisible (raveButton);
|
||||
raveButton.setTooltip ("RAVE one-click boost (unison, width, drive, OTT, reverb)");
|
||||
raveButton.setTooltip ("Adds unison, width, drive, OTT and reverb to the current patch");
|
||||
// RAVE uses a callback rather than a direct parameter attachment.
|
||||
raveButton.setOnClick ([this] (bool on) { processor.setRaveEnabled (on); });
|
||||
|
||||
// Tab bar.
|
||||
juce::TextButton* tabs[5] = { &tabOsc, &tabFilter, &tabMod, &tabFx, &tabMacro };
|
||||
const char* tabNames[5] = { "OSC", "FILTER", "MOD", "FX", "MACRO" };
|
||||
const char* tabTips[5] = { "Oscillators (Tab cycles sections)",
|
||||
"Filters (Tab cycles sections)",
|
||||
"Modulation: envelopes, LFOs and matrix (Tab cycles sections)",
|
||||
"Effects rack (Tab cycles sections)",
|
||||
"Macros (Tab cycles sections)" };
|
||||
const char* tabNames[5] = { "Osc", "Filter", "Mod", "FX", "Macro" };
|
||||
const char* tabTips[5] = { "Oscillators. Tab cycles sections.",
|
||||
"Filters. Tab cycles sections.",
|
||||
"Modulation. Envelopes, LFOs and the routing matrix.",
|
||||
"Effects rack. Tab cycles sections.",
|
||||
"Macros. Tab cycles sections." };
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
tabs[i]->setButtonText (tabNames[i]);
|
||||
@@ -262,7 +396,7 @@ void PluginEditor::buildTopBar()
|
||||
|
||||
// Master.
|
||||
masterKnob.setBounds (906, 0, 64, 60);
|
||||
masterKnob.setTooltip ("Master output volume (0-100%)");
|
||||
masterKnob.setTooltip ("Master output level, 0 % to 100 %");
|
||||
root.addAndMakeVisible (masterKnob);
|
||||
sliderAttachments.push_back (std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment> (
|
||||
processor.parameters, ids::master, masterKnob));
|
||||
@@ -274,12 +408,12 @@ void PluginEditor::buildTopBar()
|
||||
|
||||
void PluginEditor::buildOscTab()
|
||||
{
|
||||
oscView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
oscView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
|
||||
root.addChildComponent (oscView);
|
||||
|
||||
constexpr int colW = 545; // two equal columns filling kBaseW
|
||||
constexpr int oscH = 308;
|
||||
constexpr int subH = 150;
|
||||
constexpr int colW = layout::twoColW;
|
||||
constexpr int oscH = layout::oscPanelH;
|
||||
constexpr int subH = layout::smallPanelH;
|
||||
|
||||
oscAPanel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, oscH);
|
||||
oscBPanel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, oscH);
|
||||
@@ -294,9 +428,9 @@ void PluginEditor::buildOscTab()
|
||||
const int row2Y = row1Y + layout::rowHeight;
|
||||
|
||||
// --- Oscillator A ---
|
||||
makeToggle (&oscAPanel, "On", ids::oscAOn, "Enable oscillator A")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&oscAPanel, ids::oscAWave, wavetableItems(), "Oscillator A wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
|
||||
makeCombo (&oscAPanel, ids::oscAWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Oscillator A warp mode")
|
||||
makeToggle (&oscAPanel, "On", ids::oscAOn, "Turns oscillator A on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&oscAPanel, ids::oscAWave, wavetableItems(), "Selects the wavetable, 10 factory tables")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
|
||||
makeCombo (&oscAPanel, ids::oscAWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Selects the wavetable warp mode")
|
||||
->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
|
||||
|
||||
oscAWave.setWavetables (&processor.engine.getWavetables());
|
||||
@@ -315,17 +449,19 @@ void PluginEditor::buildOscTab()
|
||||
|
||||
std::vector<Knob*> rowA2 = {
|
||||
makeKnob (&oscAPanel, "Unison", ids::oscAUnison, fmtUnison, "Unison voices (1-16)"),
|
||||
makeKnob (&oscAPanel, "Detune", ids::oscADetune, formatPercent, "Unison detune (0-100%)"),
|
||||
makeKnob (&oscAPanel, "Spread", ids::oscASpread, formatPercent, "Unison spread (0-100%)"),
|
||||
makeKnob (&oscAPanel, "Phase", ids::oscAPhase, formatPercent, "Phase (0-100%)"),
|
||||
makeKnob (&oscAPanel, "Random Phase", ids::oscARandPh, formatPercent, "Random phase (0-100%)")
|
||||
makeKnob (&oscAPanel, "Detune", ids::oscADetune, formatCents, "Unison detune spread, 0 to 50 cents"),
|
||||
makeKnob (&oscAPanel, "Spread", ids::oscASpread, formatPercent, "Unison stereo spread (0-100%)"),
|
||||
makeKnob (&oscAPanel, "Phase", ids::oscAPhase, fmtDegrees, "Start phase at note on, 0 to 360 degrees"),
|
||||
makeKnob (&oscAPanel, "Rand phase", ids::oscARandPh, fmtDegrees, "Random start phase per voice, 0 to 360 degrees")
|
||||
};
|
||||
placeKnobs (rowA2, layout::padding, row2Y, layout::knobSize, layout::knobSize);
|
||||
// Unison is a whole number of voices, so the dial should rest on a detent.
|
||||
rowA2[0]->setRange (0.0, 1.0, 1.0 / (kMaxUnison - 1));
|
||||
|
||||
// --- Oscillator B ---
|
||||
makeToggle (&oscBPanel, "On", ids::oscBOn, "Enable oscillator B")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&oscBPanel, ids::oscBWave, wavetableItems(), "Oscillator B wavetable")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
|
||||
makeCombo (&oscBPanel, ids::oscBWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Oscillator B warp mode")
|
||||
makeToggle (&oscBPanel, "On", ids::oscBOn, "Turns oscillator B on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&oscBPanel, ids::oscBWave, wavetableItems(), "Selects the wavetable, 10 factory tables")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 200, layout::comboHeight);
|
||||
makeCombo (&oscBPanel, ids::oscBWarp, { "None", "Bend+", "Bend-", "Sync", "PWM", "Asym", "Mirror", "Fold" }, "Selects the wavetable warp mode")
|
||||
->setBounds (layout::padding + layout::toggleWidth + layout::gap + 200 + layout::gap, comboY, 130, layout::comboHeight);
|
||||
|
||||
oscBWave.setWavetables (&processor.engine.getWavetables());
|
||||
@@ -344,32 +480,33 @@ void PluginEditor::buildOscTab()
|
||||
|
||||
std::vector<Knob*> rowB2 = {
|
||||
makeKnob (&oscBPanel, "Unison", ids::oscBUnison, fmtUnison, "Unison voices (1-16)"),
|
||||
makeKnob (&oscBPanel, "Detune", ids::oscBDetune, formatPercent, "Unison detune (0-100%)"),
|
||||
makeKnob (&oscBPanel, "Spread", ids::oscBSpread, formatPercent, "Unison spread (0-100%)"),
|
||||
makeKnob (&oscBPanel, "Phase", ids::oscBPhase, formatPercent, "Phase (0-100%)"),
|
||||
makeKnob (&oscBPanel, "Random Phase", ids::oscBRandPh, formatPercent, "Random phase (0-100%)")
|
||||
makeKnob (&oscBPanel, "Detune", ids::oscBDetune, formatCents, "Unison detune spread, 0 to 50 cents"),
|
||||
makeKnob (&oscBPanel, "Spread", ids::oscBSpread, formatPercent, "Unison stereo spread (0-100%)"),
|
||||
makeKnob (&oscBPanel, "Phase", ids::oscBPhase, fmtDegrees, "Start phase at note on, 0 to 360 degrees"),
|
||||
makeKnob (&oscBPanel, "Rand phase", ids::oscBRandPh, fmtDegrees, "Random start phase per voice, 0 to 360 degrees")
|
||||
};
|
||||
placeKnobs (rowB2, layout::padding, row2Y, layout::knobSize, layout::knobSize);
|
||||
rowB2[0]->setRange (0.0, 1.0, 1.0 / (kMaxUnison - 1));
|
||||
|
||||
// --- Sub ---
|
||||
makeToggle (&subPanel, "On", ids::subOn, "Enable sub oscillator")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&subPanel, ids::subShape, { "Sine", "Triangle" }, "Sub oscillator waveform")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight);
|
||||
makeCombo (&subPanel, ids::subOct, { "-2 oct", "-1 oct", "0 oct" }, "Sub oscillator octave")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 130 + layout::gap, comboY, 90, layout::comboHeight);
|
||||
makeToggle (&subPanel, "On", ids::subOn, "Turns the sub oscillator on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&subPanel, ids::subShape, { "Sine", "Triangle" }, "Sub oscillator waveform, sine or triangle")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight);
|
||||
makeCombo (&subPanel, ids::subOct, { "-2 oct", "-1 oct", "0 oct" }, "Sub oscillator octave, 2 below to unison")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 130 + layout::gap, comboY, 90, layout::comboHeight);
|
||||
makeKnob (&subPanel, "Level", ids::subLevel, formatPercent, "Sub oscillator level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
|
||||
|
||||
// --- Noise ---
|
||||
makeToggle (&noisePanel, "On", ids::noiseOn, "Enable noise")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&noisePanel, ids::noiseType, { "White", "Pink" }, "Noise type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight);
|
||||
makeToggle (&noisePanel, "On", ids::noiseOn, "Turns noise on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&noisePanel, ids::noiseType, { "White", "Pink" }, "Noise colour, white or pink")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 130, layout::comboHeight);
|
||||
makeKnob (&noisePanel, "Level", ids::noiseLevel, formatPercent, "Noise level (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
|
||||
}
|
||||
|
||||
void PluginEditor::buildFilterTab()
|
||||
{
|
||||
filterView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
filterView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
|
||||
root.addChildComponent (filterView);
|
||||
|
||||
constexpr int colW = 360;
|
||||
constexpr int panelH = 300; // retains the original panel height (content top-aligned)
|
||||
constexpr int colW = layout::threeColW;
|
||||
constexpr int panelH = layout::filterPanelH;
|
||||
|
||||
filter1Panel.setBounds (gridX (0, colW, layout::panelSpacing), layout::margin, colW, panelH);
|
||||
filter2Panel.setBounds (gridX (1, colW, layout::panelSpacing), layout::margin, colW, panelH);
|
||||
@@ -385,52 +522,52 @@ void PluginEditor::buildFilterTab()
|
||||
const int knobY = displayY + layout::displayHeight + layout::padding; // aligns with OSC row 1
|
||||
|
||||
// Filter 1
|
||||
makeToggle (&filter1Panel, "On", ids::f1On, "Enable filter 1")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&filter1Panel, ids::f1Type, filterTypes, "Filter 1 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
|
||||
makeCombo (&filter1Panel, ids::f1Slope, slopes, "Filter 1 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight);
|
||||
makeToggle (&filter1Panel, "On", ids::f1On, "Turns filter 1 on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&filter1Panel, ids::f1Type, filterTypes, "Selects the filter 1 model")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
|
||||
makeCombo (&filter1Panel, ids::f1Slope, slopes, "Slope, 6, 12 or 24 dB per octave on the ladder and diode models")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight);
|
||||
filter1Panel.addAndMakeVisible (filter1Display);
|
||||
filter1Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
|
||||
|
||||
std::vector<Knob*> f1 = {
|
||||
makeKnob (&filter1Panel, "Cutoff", ids::f1Cutoff, formatHz, "Filter 1 cutoff (20 Hz - 20 kHz)"),
|
||||
makeKnob (&filter1Panel, "Resonance", ids::f1Res, formatPercent, "Filter 1 resonance (0-100%)"),
|
||||
makeKnob (&filter1Panel, "Drive", ids::f1Drive, formatPercent, "Filter 1 drive (0-100%)"),
|
||||
makeKnob (&filter1Panel, "Keytrack", ids::f1Key, formatPercent, "Filter 1 keytrack (0-100%)")
|
||||
makeKnob (&filter1Panel, "Cutoff", ids::f1Cutoff, formatHz, "Filter 1 cutoff, 20 Hz to 18 kHz"),
|
||||
makeKnob (&filter1Panel, "Resonance", ids::f1Res, formatPercent, "Filter 1 resonance, 0 % to 100 %"),
|
||||
makeKnob (&filter1Panel, "Drive", ids::f1Drive, formatPercent, "Filter 1 drive, 0 % to 100 %. Damps the comb model."),
|
||||
makeKnob (&filter1Panel, "Keytrack", ids::f1Key, formatPercent, "Filter 1 key tracking, 0 % to 100 %")
|
||||
};
|
||||
placeKnobs (f1, layout::padding, knobY, layout::knobSize, layout::knobSize);
|
||||
|
||||
// Filter 2
|
||||
makeToggle (&filter2Panel, "On", ids::f2On, "Enable filter 2")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&filter2Panel, ids::f2Type, filterTypes, "Filter 2 type")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
|
||||
makeCombo (&filter2Panel, ids::f2Slope, slopes, "Filter 2 slope")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight);
|
||||
makeToggle (&filter2Panel, "On", ids::f2On, "Turns filter 2 on or off")->setBounds (layout::padding, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&filter2Panel, ids::f2Type, filterTypes, "Selects the filter 2 model")->setBounds (layout::padding + layout::toggleWidth + layout::gap, comboY, 170, layout::comboHeight);
|
||||
makeCombo (&filter2Panel, ids::f2Slope, slopes, "Slope, 6, 12 or 24 dB per octave on the ladder and diode models")->setBounds (layout::padding + layout::toggleWidth + layout::gap + 170 + layout::gap, comboY, 100, layout::comboHeight);
|
||||
filter2Panel.addAndMakeVisible (filter2Display);
|
||||
filter2Display.setBounds (layout::padding, displayY, colW - 2 * layout::padding, layout::displayHeight);
|
||||
|
||||
std::vector<Knob*> f2 = {
|
||||
makeKnob (&filter2Panel, "Cutoff", ids::f2Cutoff, formatHz, "Filter 2 cutoff (20 Hz - 20 kHz)"),
|
||||
makeKnob (&filter2Panel, "Resonance", ids::f2Res, formatPercent, "Filter 2 resonance (0-100%)"),
|
||||
makeKnob (&filter2Panel, "Drive", ids::f2Drive, formatPercent, "Filter 2 drive (0-100%)"),
|
||||
makeKnob (&filter2Panel, "Keytrack", ids::f2Key, formatPercent, "Filter 2 keytrack (0-100%)")
|
||||
makeKnob (&filter2Panel, "Cutoff", ids::f2Cutoff, formatHz, "Filter 2 cutoff, 20 Hz to 18 kHz"),
|
||||
makeKnob (&filter2Panel, "Resonance", ids::f2Res, formatPercent, "Filter 2 resonance, 0 % to 100 %"),
|
||||
makeKnob (&filter2Panel, "Drive", ids::f2Drive, formatPercent, "Filter 2 drive, 0 % to 100 %. Damps the comb model."),
|
||||
makeKnob (&filter2Panel, "Keytrack", ids::f2Key, formatPercent, "Filter 2 key tracking, 0 % to 100 %")
|
||||
};
|
||||
placeKnobs (f2, layout::padding, knobY, layout::knobSize, layout::knobSize);
|
||||
|
||||
// Routing
|
||||
makeCombo (&routingPanel, ids::fRoute, { "Serial", "Parallel", "Split" }, "Filter routing (serial / parallel / split)")->setBounds (layout::padding, comboY, 160, layout::comboHeight);
|
||||
makeKnob (&routingPanel, "Mix", ids::fMix, formatPercent, "Filter mix (0-100%)")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
|
||||
makeKnob (&routingPanel, "Output", ids::fOut, formatPercent, "Filter output (0-100%)")->setBounds (layout::padding + layout::knobSize + layout::gap, displayY, layout::knobSize, layout::knobSize);
|
||||
makeCombo (&routingPanel, ids::fRoute, { "Serial", "Parallel", "Split" }, "Filter routing, serial, parallel or split")->setBounds (layout::padding, comboY, 160, layout::comboHeight);
|
||||
makeKnob (&routingPanel, "Mix", ids::fMix, formatPercent, "Blends filter 2 over filter 1 in parallel routing, 0 % to 100 %")->setBounds (layout::padding, displayY, layout::knobSize, layout::knobSize);
|
||||
makeKnob (&routingPanel, "Output", ids::fOut, fmtFilterOut, "Filter output gain, 0 % to 150 %")->setBounds (layout::padding + layout::knobSize + layout::gap, displayY, layout::knobSize, layout::knobSize);
|
||||
}
|
||||
|
||||
void PluginEditor::buildModTab()
|
||||
{
|
||||
modView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
modView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
|
||||
root.addChildComponent (modView);
|
||||
|
||||
const juce::StringArray lfoShapes = { "Sine", "Triangle", "Saw", "Square", "S&H", "Step", "Freehand" };
|
||||
|
||||
constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100
|
||||
constexpr int colGap = 8;
|
||||
constexpr int envH = 154;
|
||||
constexpr int lfoH = 206;
|
||||
constexpr int colW = layout::fourColW;
|
||||
constexpr int colGap = layout::fourColGap;
|
||||
constexpr int envH = layout::envPanelH;
|
||||
constexpr int lfoH = layout::lfoPanelH;
|
||||
|
||||
// Envelopes (4 across).
|
||||
for (int i = 0; i < kNumEnvelopes; ++i)
|
||||
@@ -449,11 +586,11 @@ void PluginEditor::buildModTab()
|
||||
const auto& idsC = paramIds::envCurve;
|
||||
|
||||
std::vector<Knob*> knobs = {
|
||||
makeKnob (&envPanels[(size_t) i], "Attack", idsA[i], formatSeconds, "Attack time (0.5 ms - 12 s)"),
|
||||
makeKnob (&envPanels[(size_t) i], "Decay", idsD[i], formatSeconds, "Decay time (0.5 ms - 12 s)"),
|
||||
makeKnob (&envPanels[(size_t) i], "Sustain", idsS[i], formatPercent, "Sustain level (0-100%)"),
|
||||
makeKnob (&envPanels[(size_t) i], "Release", idsR[i], formatSeconds, "Release time (0.5 ms - 12 s)"),
|
||||
makeKnob (&envPanels[(size_t) i], "Curve", idsC[i], formatPercent, "Envelope curve (0-100%)")
|
||||
makeKnob (&envPanels[(size_t) i], "Attack", idsA[i], formatSeconds, "Attack time, 0.5 ms to 12 s"),
|
||||
makeKnob (&envPanels[(size_t) i], "Decay", idsD[i], formatSeconds, "Decay time, 0.5 ms to 12 s"),
|
||||
makeKnob (&envPanels[(size_t) i], "Sustain", idsS[i], formatPercent, "Sustain level, 0 % to 100 %"),
|
||||
makeKnob (&envPanels[(size_t) i], "Release", idsR[i], formatSeconds, "Release time, 0.5 ms to 12 s"),
|
||||
makeKnob (&envPanels[(size_t) i], "Curve", idsC[i], formatPercent, "Curve shape. Low is a fast attack and slow decay, high is the reverse.")
|
||||
};
|
||||
placeKnobs (knobs, layout::padding,
|
||||
layout::titleHeight + layout::envDisplayHeight + layout::padding,
|
||||
@@ -475,18 +612,18 @@ void PluginEditor::buildModTab()
|
||||
const auto& fade = paramIds::lfoFade;
|
||||
const auto& delay = paramIds::lfoDelay;
|
||||
|
||||
makeCombo (&lfoPanels[(size_t) i], shape[i], lfoShapes, "LFO shape")->setBounds (layout::padding, comboRowY(), 110, layout::comboHeight);
|
||||
makeToggle (&lfoPanels[(size_t) i], "Sync", sync[i], "Tempo-sync LFO")->setBounds (layout::padding + 110 + layout::gap, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
makeCombo (&lfoPanels[(size_t) i], shape[i], lfoShapes, "Selects the LFO waveform")->setBounds (layout::padding, comboRowY(), 110, layout::comboHeight);
|
||||
makeToggle (&lfoPanels[(size_t) i], "Sync", sync[i], "Locks the LFO to host tempo and uses the Beat knob")->setBounds (layout::padding + 110 + layout::gap, layout::titleHeight, layout::toggleWidth, layout::toggleHeight);
|
||||
|
||||
lfoPanels[(size_t) i].addAndMakeVisible (lfoDisplays[(size_t) i]);
|
||||
lfoDisplays[(size_t) i].setBounds (layout::padding, bodyTop(), colW - 2 * layout::padding, layout::lfoDisplayHeight);
|
||||
|
||||
std::vector<Knob*> knobs = {
|
||||
makeKnob (&lfoPanels[(size_t) i], "Rate", rate[i], formatPercent, "LFO rate (0.02 Hz - 30 Hz)"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Beat", beat[i], formatPercent, "Beat division (1/32 - 4 bars)"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Phase", phase[i], formatPercent, "Phase (0-100%)"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Fade", fade[i], formatPercent, "Fade in (0-100%)"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Delay", delay[i], formatPercent, "Start delay (0-100%)")
|
||||
makeKnob (&lfoPanels[(size_t) i], "Rate", rate[i], fmtLfoRate, "LFO rate, 0.02 Hz to 30 Hz"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Beat", beat[i], fmtLfoBeat, "Cycle length, 1/32 beat to 16 beats"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Phase", phase[i], fmtDegrees, "Start phase, 0 to 360 degrees"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Fade", fade[i], fmtLfoFade, "Fade in time, 0 s to 8 s"),
|
||||
makeKnob (&lfoPanels[(size_t) i], "Delay", delay[i], fmtLfoDelay, "Start delay, 0 s to 4 s")
|
||||
};
|
||||
placeKnobs (knobs, layout::padding,
|
||||
bodyTop() + layout::lfoDisplayHeight + layout::padding,
|
||||
@@ -500,65 +637,70 @@ void PluginEditor::buildModTab()
|
||||
|
||||
// Modulation matrix.
|
||||
const int matrixTop = lfoTop + lfoH + layout::panelSpacing;
|
||||
const int matrixH = kBaseH - 60 - matrixTop - layout::margin;
|
||||
const int matrixH = kBaseH - layout::tabBarHeight - matrixTop - layout::margin;
|
||||
matrixPanel.setBounds (layout::margin, matrixTop, kBaseW - 2 * layout::margin, matrixH);
|
||||
modView.addAndMakeVisible (matrixPanel);
|
||||
|
||||
std::vector<ModTarget> targetEnums;
|
||||
const juce::StringArray targetItems = modTargetItems (targetEnums);
|
||||
|
||||
const int mCenter = layout::titleHeight + 30; // vertical centre of the (60px) depth knob
|
||||
const int mCenter = layout::headerCentreY; // centre line of the depth row
|
||||
|
||||
modSourceCombo.addItemList (modSourceItems(), 1);
|
||||
modSourceCombo.setSelectedItemIndex (0, juce::dontSendNotification);
|
||||
modSourceCombo.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 160, layout::comboHeight);
|
||||
modSourceCombo.setTooltip ("Modulation source");
|
||||
modSourceCombo.setTooltip ("Selects the modulation source");
|
||||
matrixPanel.addAndMakeVisible (modSourceCombo);
|
||||
|
||||
modTargetCombo.addItemList (targetItems, 1);
|
||||
modTargetCombo.setSelectedItemIndex (0, juce::dontSendNotification);
|
||||
modTargetCombo.setBounds (layout::padding + 160 + layout::gap, mCenter - layout::comboHeight / 2, 200, layout::comboHeight);
|
||||
modTargetCombo.setTooltip ("Modulation destination");
|
||||
modTargetCombo.setTooltip ("Selects the modulation destination");
|
||||
matrixPanel.addAndMakeVisible (modTargetCombo);
|
||||
|
||||
const int depthX = layout::padding + 160 + layout::gap + 200 + layout::gap;
|
||||
modDepthKnob.setRange (-1.0, 1.0);
|
||||
modDepthKnob.setValue (0.5, juce::dontSendNotification);
|
||||
modDepthKnob.setFormatter (fmtDepth);
|
||||
modDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60);
|
||||
modDepthKnob.setTooltip ("Modulation depth (-1 to +1)");
|
||||
modDepthKnob.setDefaultValue (0.0); // a double click recentres the depth
|
||||
modDepthKnob.setFormatter (fmtModDepth);
|
||||
modDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, layout::depthKnobHeight);
|
||||
modDepthKnob.setTooltip ("Modulation amount for the next Add, -100 % to +100 % of the target range");
|
||||
matrixPanel.addAndMakeVisible (modDepthKnob);
|
||||
|
||||
modBipolarToggle.setLabel ("Bipolar");
|
||||
modBipolarToggle.setBounds (depthX + layout::knobSize + layout::gap, mCenter - layout::toggleHeight / 2, 60, layout::toggleHeight);
|
||||
modBipolarToggle.setTooltip ("Bipolar modulation depth");
|
||||
modBipolarToggle.setBounds (depthX + layout::knobSize + layout::gap, mCenter - layout::toggleHeight / 2, layout::toggleWide, layout::toggleHeight);
|
||||
modBipolarToggle.setTooltip ("Recentres a one-sided source so the modulation swings both ways, for the next Add");
|
||||
matrixPanel.addAndMakeVisible (modBipolarToggle);
|
||||
modBipolarToggle.setToggleState (true);
|
||||
|
||||
const int addX = depthX + layout::knobSize + layout::gap + 60 + layout::gap;
|
||||
const int addX = depthX + layout::knobSize + layout::gap + layout::toggleWide + layout::gap;
|
||||
modAddButton.setButtonText ("Add");
|
||||
modAddButton.setBounds (addX, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight);
|
||||
modAddButton.setTooltip ("Add modulation connection");
|
||||
modAddButton.setTooltip ("Adds the selected source and destination to the matrix");
|
||||
modAddButton.onClick = [this, targetEnums]
|
||||
{
|
||||
const ModSource src = (ModSource) modSourceCombo.getSelectedItemIndex();
|
||||
const int tid = modTargetCombo.getSelectedItemIndex();
|
||||
if (tid >= 0 && tid < (int) targetEnums.size())
|
||||
{
|
||||
bool added = false;
|
||||
{
|
||||
const juce::ScopedLock lock (processor.engine.getControlLock());
|
||||
processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid],
|
||||
(float) modDepthKnob.getValue(),
|
||||
modBipolarToggle.getToggleState());
|
||||
added = processor.engine.getMatrix().addConnection (src, targetEnums[(size_t) tid],
|
||||
(float) modDepthKnob.getValue(),
|
||||
modBipolarToggle.getToggleState());
|
||||
}
|
||||
updateModList();
|
||||
if (added)
|
||||
updateModList();
|
||||
else
|
||||
modAddButton.setEnabled (false); // at cap: keep list text and selection unchanged
|
||||
}
|
||||
};
|
||||
matrixPanel.addAndMakeVisible (modAddButton);
|
||||
|
||||
modRemoveButton.setButtonText ("Remove Last");
|
||||
modRemoveButton.setBounds (addX + 70 + layout::gap, mCenter - layout::buttonHeight / 2, 110, layout::buttonHeight);
|
||||
modRemoveButton.setTooltip ("Remove last modulation connection");
|
||||
modRemoveButton.setTooltip ("Removes the newest matrix row, older rows stay");
|
||||
modRemoveButton.onClick = [this]
|
||||
{
|
||||
{
|
||||
@@ -573,7 +715,7 @@ void PluginEditor::buildModTab()
|
||||
|
||||
modClearButton.setButtonText ("Clear");
|
||||
modClearButton.setBounds (addX + 70 + layout::gap + 110 + layout::gap, mCenter - layout::buttonHeight / 2, 70, layout::buttonHeight);
|
||||
modClearButton.setTooltip ("Clear all modulation connections");
|
||||
modClearButton.setTooltip ("Removes every modulation connection");
|
||||
modClearButton.onClick = [this]
|
||||
{
|
||||
{
|
||||
@@ -584,11 +726,12 @@ void PluginEditor::buildModTab()
|
||||
};
|
||||
matrixPanel.addAndMakeVisible (modClearButton);
|
||||
|
||||
const int listTop = layout::titleHeight + 60 + layout::padding;
|
||||
const int listTop = layout::titleHeight + layout::depthKnobHeight + layout::padding;
|
||||
modList.setBounds (layout::padding, listTop,
|
||||
kBaseW - 2 * layout::margin - 2 * layout::padding,
|
||||
matrixH - listTop - layout::padding);
|
||||
modList.setReadOnly (true);
|
||||
modList.setTooltip ("Every modulation connection, newest last. Depth is a fraction of the target range.");
|
||||
modList.setMultiLine (true, false);
|
||||
modList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
|
||||
modList.setColour (juce::TextEditor::textColourId, theme::text);
|
||||
@@ -598,14 +741,17 @@ void PluginEditor::buildModTab()
|
||||
|
||||
void PluginEditor::buildFxTab()
|
||||
{
|
||||
fxView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
fxView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
|
||||
root.addChildComponent (fxView);
|
||||
|
||||
const juce::StringArray fxTypes = { "Off", "Hyper", "Chorus", "Flanger", "Phaser",
|
||||
"Distortion", "EQ", "Compressor", "Delay", "Reverb" };
|
||||
// Built from the one label function so the item order always follows FxType,
|
||||
// whose values are used directly as the combo selection / parameter value.
|
||||
juce::StringArray fxTypes;
|
||||
for (int t = 0; t < (int) FxType::Count; ++t)
|
||||
fxTypes.add (FXProcessor::fxTypeName (t));
|
||||
|
||||
constexpr int colW = 545;
|
||||
constexpr int slotH = 150;
|
||||
constexpr int colW = layout::twoColW;
|
||||
constexpr int slotH = layout::smallPanelH;
|
||||
|
||||
for (int i = 0; i < kNumFxSlots; ++i)
|
||||
{
|
||||
@@ -637,13 +783,17 @@ void PluginEditor::buildFxTab()
|
||||
|
||||
fxUp[(size_t) i]->onClick = [this, i] { if (i > 0) swapFxSlots (i, i - 1); };
|
||||
fxDown[(size_t) i]->onClick = [this, i] { if (i < kNumFxSlots - 1) swapFxSlots (i, i + 1); };
|
||||
// The first slot cannot move up and the last cannot move down, so grey
|
||||
// the arrow out instead of leaving a click that does nothing.
|
||||
fxUp[(size_t) i]->setEnabled (i > 0);
|
||||
fxDown[(size_t) i]->setEnabled (i < kNumFxSlots - 1);
|
||||
|
||||
std::vector<Knob*> knobs = {
|
||||
makeKnob (&fxPanels[(size_t) i], "Mix", kFxMix[i], formatPercent, "Effect mix (0-100%)"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P1", kFxP1[i], formatPercent, "Effect parameter 1 (0-100%)"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P2", kFxP2[i], formatPercent, "Effect parameter 2 (0-100%)"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P3", kFxP3[i], formatPercent, "Effect parameter 3 (0-100%)"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P4", kFxP4[i], formatPercent, "Effect parameter 4 (0-100%)")
|
||||
makeKnob (&fxPanels[(size_t) i], "Mix", kFxMix[i], formatPercent, "Dry and wet mix, 0 % to 100 %"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P1", kFxP1[i], formatPercent, "Effect parameter 1, set by the effect type"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P2", kFxP2[i], formatPercent, "Effect parameter 2, set by the effect type"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P3", kFxP3[i], formatPercent, "Effect parameter 3, set by the effect type"),
|
||||
makeKnob (&fxPanels[(size_t) i], "P4", kFxP4[i], formatPercent, "Effect parameter 4, set by the effect type")
|
||||
};
|
||||
placeKnobs (knobs, layout::padding, bodyTop(), layout::knobSize, layout::knobSize);
|
||||
fxKnobs[(size_t) i] = knobs;
|
||||
@@ -652,12 +802,12 @@ void PluginEditor::buildFxTab()
|
||||
|
||||
void PluginEditor::buildMacroTab()
|
||||
{
|
||||
macroView.setBounds (0, 60, kBaseW, kBaseH - 60);
|
||||
macroView.setBounds (0, layout::tabBarHeight, kBaseW, kBaseH - layout::tabBarHeight);
|
||||
root.addChildComponent (macroView);
|
||||
|
||||
constexpr int colW = 269; // four equal columns: 4*269 + 3*8 = 1100
|
||||
constexpr int colGap = 8;
|
||||
constexpr int macroH = 158;
|
||||
constexpr int colW = layout::fourColW;
|
||||
constexpr int colGap = layout::fourColGap;
|
||||
constexpr int macroH = layout::macroPanelH;
|
||||
|
||||
for (int i = 0; i < kNumMacros; ++i)
|
||||
{
|
||||
@@ -674,56 +824,68 @@ void PluginEditor::buildMacroTab()
|
||||
|
||||
// Macro assignment editor.
|
||||
const int assignTop = layout::margin + macroH + layout::panelSpacing;
|
||||
const int assignH = kBaseH - 60 - assignTop - layout::margin;
|
||||
const int assignH = kBaseH - layout::tabBarHeight - assignTop - layout::margin;
|
||||
macroAssignPanel.setBounds (layout::margin, assignTop, kBaseW - 2 * layout::margin, assignH);
|
||||
macroView.addAndMakeVisible (macroAssignPanel);
|
||||
|
||||
std::vector<ModTarget> targetEnums;
|
||||
const juce::StringArray targetItems = modTargetItems (targetEnums);
|
||||
|
||||
const int mCenter = layout::titleHeight + 30; // vertical centre of the (60px) depth knob
|
||||
const int mCenter = layout::headerCentreY; // centre line of the depth row
|
||||
|
||||
macroAssignIndex.addItemList ({ "Macro 1", "Macro 2", "Macro 3", "Macro 4" }, 1);
|
||||
juce::StringArray macroItems;
|
||||
for (int i = 0; i < kNumMacros; ++i)
|
||||
macroItems.add (MacroControls::macroName (i));
|
||||
macroAssignIndex.addItemList (macroItems, 1);
|
||||
macroAssignIndex.setSelectedItemIndex (0, juce::dontSendNotification);
|
||||
macroAssignIndex.setBounds (layout::padding, mCenter - layout::comboHeight / 2, 140, layout::comboHeight);
|
||||
macroAssignIndex.setTooltip ("Macro to assign");
|
||||
macroAssignIndex.setTooltip ("Selects the macro to edit");
|
||||
macroAssignIndex.onChange = [this] { updateMacroList(); };
|
||||
macroAssignPanel.addAndMakeVisible (macroAssignIndex);
|
||||
|
||||
macroAssignTarget.addItemList (targetItems, 1);
|
||||
macroAssignTarget.setSelectedItemIndex (0, juce::dontSendNotification);
|
||||
macroAssignTarget.setBounds (layout::padding + 140 + layout::gap, mCenter - layout::comboHeight / 2, 220, layout::comboHeight);
|
||||
macroAssignTarget.setTooltip ("Destination parameter");
|
||||
macroAssignTarget.setTooltip ("Selects the destination parameter");
|
||||
macroAssignPanel.addAndMakeVisible (macroAssignTarget);
|
||||
|
||||
const int depthX = layout::padding + 140 + layout::gap + 220 + layout::gap;
|
||||
macroDepthKnob.setFormatter (fmtDepth);
|
||||
macroDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, 60);
|
||||
macroDepthKnob.setTooltip ("Macro assignment depth (0 to 1)");
|
||||
// Half depth, so an Assign made without touching this knob still moves the
|
||||
// destination. A depth of zero would create a silent assignment.
|
||||
macroDepthKnob.setValue (0.5, juce::dontSendNotification);
|
||||
macroDepthKnob.setDefaultValue (0.5);
|
||||
macroDepthKnob.setFormatter (formatPercent);
|
||||
macroDepthKnob.setBounds (depthX, layout::titleHeight, layout::knobSize, layout::depthKnobHeight);
|
||||
macroDepthKnob.setTooltip ("Macro amount for the next Assign, 0 % to 100 % of the target range");
|
||||
macroAssignPanel.addAndMakeVisible (macroDepthKnob);
|
||||
|
||||
const int assignBtnX = depthX + layout::knobSize + layout::gap;
|
||||
macroAssignButton.setButtonText ("Assign");
|
||||
macroAssignButton.setBounds (assignBtnX, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight);
|
||||
macroAssignButton.setTooltip ("Assign destination to the selected macro");
|
||||
macroAssignButton.setTooltip ("Adds this destination to the selected macro");
|
||||
macroAssignButton.onClick = [this, targetEnums]
|
||||
{
|
||||
const int macro = macroAssignIndex.getSelectedItemIndex();
|
||||
const int tid = macroAssignTarget.getSelectedItemIndex();
|
||||
if (tid >= 0 && tid < (int) targetEnums.size())
|
||||
if (macro >= 0 && macro < kNumMacros && tid >= 0 && tid < (int) targetEnums.size())
|
||||
{
|
||||
bool added = false;
|
||||
{
|
||||
const juce::ScopedLock lock (processor.engine.getControlLock());
|
||||
processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid],
|
||||
(float) macroDepthKnob.getValue());
|
||||
added = processor.engine.getMacros().addAssignment (macro, targetEnums[(size_t) tid],
|
||||
(float) macroDepthKnob.getValue());
|
||||
}
|
||||
updateMacroList();
|
||||
if (added)
|
||||
updateMacroList();
|
||||
else
|
||||
macroAssignButton.setEnabled (false); // at cap: keep list text and selection unchanged
|
||||
}
|
||||
};
|
||||
macroAssignPanel.addAndMakeVisible (macroAssignButton);
|
||||
|
||||
macroClearButton.setButtonText ("Clear All");
|
||||
macroClearButton.setBounds (assignBtnX + 80 + layout::gap, mCenter - layout::buttonHeight / 2, 80, layout::buttonHeight);
|
||||
macroClearButton.setTooltip ("Clear all macro assignments");
|
||||
macroClearButton.setTooltip ("Removes every macro assignment from all four macros");
|
||||
macroClearButton.onClick = [this]
|
||||
{
|
||||
{
|
||||
@@ -734,11 +896,12 @@ void PluginEditor::buildMacroTab()
|
||||
};
|
||||
macroAssignPanel.addAndMakeVisible (macroClearButton);
|
||||
|
||||
const int listTop = layout::titleHeight + 60 + layout::padding;
|
||||
const int listTop = layout::titleHeight + layout::depthKnobHeight + layout::padding;
|
||||
macroList.setBounds (layout::padding, listTop,
|
||||
kBaseW - 2 * layout::margin - 2 * layout::padding,
|
||||
assignH - listTop - layout::padding);
|
||||
macroList.setReadOnly (true);
|
||||
macroList.setTooltip ("Every macro assignment, grouped by macro. Depth is a fraction of the target range.");
|
||||
macroList.setMultiLine (true, false);
|
||||
macroList.setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack);
|
||||
macroList.setColour (juce::TextEditor::textColourId, theme::text);
|
||||
@@ -751,6 +914,7 @@ void PluginEditor::swapFxSlots (int a, int b)
|
||||
if (a < 0 || b < 0 || a >= kNumFxSlots || b >= kNumFxSlots || a == b)
|
||||
return;
|
||||
|
||||
const juce::ScopedLock lock (processor.engine.getControlLock());
|
||||
auto swapParam = [this] (const char* pa, const char* pb)
|
||||
{
|
||||
auto* p1 = processor.parameters.getParameter (pa);
|
||||
@@ -897,12 +1061,18 @@ void PluginEditor::updateVisuals()
|
||||
for (int p = 0; p < 4; ++p)
|
||||
{
|
||||
auto* knob = fxKnobs[(size_t) i][(size_t) p + 1];
|
||||
const juce::String name = kFxParamNames[type][p];
|
||||
const auto& spec = kFxParamDisplay[type][p];
|
||||
|
||||
// Label, tooltip and readout unit all come from the one table, so
|
||||
// a renamed knob can never keep the previous effect's tooltip or
|
||||
// show a percentage for a value the DSP reads in Hz or dB.
|
||||
const juce::String name = spec.name;
|
||||
if (knob->getName() != name)
|
||||
{
|
||||
knob->setName (name);
|
||||
knob->setTooltip (name == "Unused" ? "Not used by this effect"
|
||||
: name + " (normalised 0-100%)");
|
||||
knob->setTooltip (spec.tooltip);
|
||||
knob->setFormatter (spec.formatter);
|
||||
knob->setEnabled (name != "Unused");
|
||||
knob->repaint();
|
||||
}
|
||||
}
|
||||
@@ -926,26 +1096,33 @@ void PluginEditor::updateVisuals()
|
||||
void PluginEditor::updateModList()
|
||||
{
|
||||
juce::String text;
|
||||
int count = 0;
|
||||
{
|
||||
const juce::ScopedLock lock (processor.engine.getControlLock());
|
||||
const auto& cons = processor.engine.getMatrix().connections;
|
||||
count = (int) cons.size();
|
||||
for (const auto& c : cons)
|
||||
text += modSourceName (c.source) + " -> " + modTargetName (c.target)
|
||||
+ " [" + juce::String (c.depth, 2) + (c.bipolar ? ", bipolar]" : "]") + "\n";
|
||||
}
|
||||
if (modList.getText() != text)
|
||||
modList.setText (text, false);
|
||||
modAddButton.setEnabled (count < ModulationMatrix::kMaxConnections);
|
||||
}
|
||||
|
||||
void PluginEditor::updateMacroList()
|
||||
{
|
||||
juce::String text;
|
||||
int assigned = 0;
|
||||
const int macro = macroAssignIndex.getSelectedItemIndex();
|
||||
{
|
||||
const juce::ScopedLock lock (processor.engine.getControlLock());
|
||||
for (int m = 0; m < kNumMacros; ++m)
|
||||
{
|
||||
text += MacroControls::macroName (m) + ":\n";
|
||||
const auto& assigns = processor.engine.getMacros().assignments[(size_t) m];
|
||||
if (m == macro)
|
||||
assigned = (int) assigns.size();
|
||||
if (assigns.empty())
|
||||
text += " (none)\n";
|
||||
for (const auto& a : assigns)
|
||||
@@ -954,6 +1131,8 @@ void PluginEditor::updateMacroList()
|
||||
}
|
||||
if (macroList.getText() != text)
|
||||
macroList.setText (text, false);
|
||||
macroAssignButton.setEnabled (macro >= 0 && macro < kNumMacros
|
||||
&& assigned < MacroControls::kMaxAssignments);
|
||||
}
|
||||
|
||||
void PluginEditor::timerCallback()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include <JuceHeader.h>
|
||||
#include "PluginProcessor.h"
|
||||
#include "GUI/SerumLookAndFeel.h"
|
||||
#include "GUI/Knob.h"
|
||||
#include "GUI/Slider.h"
|
||||
#include "GUI/ToggleButton.h"
|
||||
@@ -36,7 +35,6 @@ public:
|
||||
|
||||
private:
|
||||
SerumAltAudioProcessor& processor;
|
||||
SerumLookAndFeel laf;
|
||||
|
||||
static constexpr int kBaseW = 1120;
|
||||
static constexpr int kBaseH = 740;
|
||||
@@ -63,7 +61,6 @@ private:
|
||||
Panel oscAPanel { "Oscillator A" }, oscBPanel { "Oscillator B" };
|
||||
Panel subPanel { "Sub" }, noisePanel { "Noise" };
|
||||
WaveformDisplay oscAWave, oscBWave;
|
||||
std::vector<juce::Component*> oscAComponents, oscBComponents;
|
||||
|
||||
// --- FILTER ---
|
||||
Panel filter1Panel { "Filter 1" }, filter2Panel { "Filter 2" }, routingPanel { "Routing" };
|
||||
|
||||
+39
-39
@@ -1,6 +1,7 @@
|
||||
#include "PluginProcessor.h"
|
||||
#include "PluginEditor.h"
|
||||
#include "Presets/FactoryPresets.h"
|
||||
#include "ExactCompare.h"
|
||||
|
||||
namespace serum
|
||||
{
|
||||
@@ -98,12 +99,12 @@ juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::crea
|
||||
params.push_back (f (ids::fMix, "Filter Mix", 0.5f));
|
||||
params.push_back (f (ids::fOut, "Filter Out", 0.667f));
|
||||
|
||||
// Envelopes 1..4
|
||||
const char* envA[4] = { ids::env1A, ids::env2A, ids::env3A, ids::env4A };
|
||||
const char* envD[4] = { ids::env1D, ids::env2D, ids::env3D, ids::env4D };
|
||||
const char* envS[4] = { ids::env1S, ids::env2S, ids::env3S, ids::env4S };
|
||||
const char* envR[4] = { ids::env1R, ids::env2R, ids::env3R, ids::env4R };
|
||||
const char* envC[4] = { ids::env1Curve, ids::env2Curve, ids::env3Curve, ids::env4Curve };
|
||||
// Envelopes 1..4 (IDs come from the shared paramIds groups)
|
||||
constexpr auto& envA = paramIds::envAttack;
|
||||
constexpr auto& envD = paramIds::envDecay;
|
||||
constexpr auto& envS = paramIds::envSustain;
|
||||
constexpr auto& envR = paramIds::envRelease;
|
||||
constexpr auto& envC = paramIds::envCurve;
|
||||
const float envADef[4] = { 0.05f, 0.1f, 0.2f, 0.2f };
|
||||
const float envDDef[4] = { 0.25f, 0.3f, 0.3f, 0.3f };
|
||||
const float envSDef[4] = { 0.8f, 0.5f, 0.5f, 0.5f };
|
||||
@@ -117,14 +118,14 @@ juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::crea
|
||||
params.push_back (f (envC[i], juce::String ("Env ") + juce::String (i + 1) + " Curve", 0.5f));
|
||||
}
|
||||
|
||||
// LFOs 1..4
|
||||
const char* lfoRate[4] = { ids::lfo1Rate, ids::lfo2Rate, ids::lfo3Rate, ids::lfo4Rate };
|
||||
const char* lfoSync[4] = { ids::lfo1Sync, ids::lfo2Sync, ids::lfo3Sync, ids::lfo4Sync };
|
||||
const char* lfoBeat[4] = { ids::lfo1Beat, ids::lfo2Beat, ids::lfo3Beat, ids::lfo4Beat };
|
||||
const char* lfoShape[4] = { ids::lfo1Shape, ids::lfo2Shape, ids::lfo3Shape, ids::lfo4Shape };
|
||||
const char* lfoPhase[4] = { ids::lfo1Phase, ids::lfo2Phase, ids::lfo3Phase, ids::lfo4Phase };
|
||||
const char* lfoFade[4] = { ids::lfo1Fade, ids::lfo2Fade, ids::lfo3Fade, ids::lfo4Fade };
|
||||
const char* lfoDelay[4] = { ids::lfo1Delay, ids::lfo2Delay, ids::lfo3Delay, ids::lfo4Delay };
|
||||
// LFOs 1..4 (IDs come from the shared paramIds groups)
|
||||
constexpr auto& lfoRate = paramIds::lfoRate;
|
||||
constexpr auto& lfoSync = paramIds::lfoSync;
|
||||
constexpr auto& lfoBeat = paramIds::lfoBeat;
|
||||
constexpr auto& lfoShape = paramIds::lfoShape;
|
||||
constexpr auto& lfoPhase = paramIds::lfoPhase;
|
||||
constexpr auto& lfoFade = paramIds::lfoFade;
|
||||
constexpr auto& lfoDelay = paramIds::lfoDelay;
|
||||
const float lfoShapeDef[4] = { 0.0f, 1.0f / 6.0f, 2.0f / 6.0f, 3.0f / 6.0f };
|
||||
for (int i = 0; i < 4; ++i)
|
||||
{
|
||||
@@ -138,37 +139,31 @@ juce::AudioProcessorValueTreeState::ParameterLayout SerumAltAudioProcessor::crea
|
||||
params.push_back (f (lfoDelay[i], n + " Delay", 0.0f));
|
||||
}
|
||||
|
||||
// FX slots 1..8
|
||||
const char* fxType[8] = { ids::fx1Type, ids::fx2Type, ids::fx3Type, ids::fx4Type,
|
||||
ids::fx5Type, ids::fx6Type, ids::fx7Type, ids::fx8Type };
|
||||
const char* fxMix[8] = { ids::fx1Mix, ids::fx2Mix, ids::fx3Mix, ids::fx4Mix,
|
||||
ids::fx5Mix, ids::fx6Mix, ids::fx7Mix, ids::fx8Mix };
|
||||
const char* fxP[8][4] = {
|
||||
{ ids::fx1P1, ids::fx1P2, ids::fx1P3, ids::fx1P4 },
|
||||
{ ids::fx2P1, ids::fx2P2, ids::fx2P3, ids::fx2P4 },
|
||||
{ ids::fx3P1, ids::fx3P2, ids::fx3P3, ids::fx3P4 },
|
||||
{ ids::fx4P1, ids::fx4P2, ids::fx4P3, ids::fx4P4 },
|
||||
{ ids::fx5P1, ids::fx5P2, ids::fx5P3, ids::fx5P4 },
|
||||
{ ids::fx6P1, ids::fx6P2, ids::fx6P3, ids::fx6P4 },
|
||||
{ ids::fx7P1, ids::fx7P2, ids::fx7P3, ids::fx7P4 },
|
||||
{ ids::fx8P1, ids::fx8P2, ids::fx8P3, ids::fx8P4 }
|
||||
};
|
||||
// FX slots 1..8 (IDs come from the shared paramIds groups)
|
||||
constexpr auto& fxType = paramIds::fxType;
|
||||
constexpr auto& fxMix = paramIds::fxMix;
|
||||
const char* const fxP[4][8] = { { paramIds::fxP1[0], paramIds::fxP1[1], paramIds::fxP1[2], paramIds::fxP1[3],
|
||||
paramIds::fxP1[4], paramIds::fxP1[5], paramIds::fxP1[6], paramIds::fxP1[7] },
|
||||
{ paramIds::fxP2[0], paramIds::fxP2[1], paramIds::fxP2[2], paramIds::fxP2[3],
|
||||
paramIds::fxP2[4], paramIds::fxP2[5], paramIds::fxP2[6], paramIds::fxP2[7] },
|
||||
{ paramIds::fxP3[0], paramIds::fxP3[1], paramIds::fxP3[2], paramIds::fxP3[3],
|
||||
paramIds::fxP3[4], paramIds::fxP3[5], paramIds::fxP3[6], paramIds::fxP3[7] },
|
||||
{ paramIds::fxP4[0], paramIds::fxP4[1], paramIds::fxP4[2], paramIds::fxP4[3],
|
||||
paramIds::fxP4[4], paramIds::fxP4[5], paramIds::fxP4[6], paramIds::fxP4[7] } };
|
||||
for (int i = 0; i < 8; ++i)
|
||||
{
|
||||
const juce::String n = juce::String ("FX ") + juce::String (i + 1);
|
||||
params.push_back (f (fxType[i], n + " Type", 0.0f));
|
||||
params.push_back (f (fxMix[i], n + " Mix", 0.5f));
|
||||
params.push_back (f (fxP[i][0], n + " P1", 0.5f));
|
||||
params.push_back (f (fxP[i][1], n + " P2", 0.5f));
|
||||
params.push_back (f (fxP[i][2], n + " P3", 0.5f));
|
||||
params.push_back (f (fxP[i][3], n + " P4", 0.5f));
|
||||
params.push_back (f (fxP[0][i], n + " P1", 0.5f));
|
||||
params.push_back (f (fxP[1][i], n + " P2", 0.5f));
|
||||
params.push_back (f (fxP[2][i], n + " P3", 0.5f));
|
||||
params.push_back (f (fxP[3][i], n + " P4", 0.5f));
|
||||
}
|
||||
|
||||
// Macros
|
||||
params.push_back (f (ids::macro1, "Macro 1", 0.0f));
|
||||
params.push_back (f (ids::macro2, "Macro 2", 0.0f));
|
||||
params.push_back (f (ids::macro3, "Macro 3", 0.0f));
|
||||
params.push_back (f (ids::macro4, "Macro 4", 0.0f));
|
||||
// Macros (display names come from MacroControls::macroName, the single source)
|
||||
for (int i = 0; i < kNumMacros; ++i)
|
||||
params.push_back (f (paramIds::macros[i], MacroControls::macroName (i), 0.0f));
|
||||
|
||||
return { params.begin(), params.end() };
|
||||
}
|
||||
@@ -266,6 +261,9 @@ void SerumAltAudioProcessor::loadFactoryPreset (int index)
|
||||
continue;
|
||||
|
||||
restoreLfoShapesFromState ({});
|
||||
// A preset starts from clean DSP state, so the previous preset's filter,
|
||||
// delay and reverb tails cannot bleed into the new one.
|
||||
engine.requestReset();
|
||||
currentProgram.store (index);
|
||||
}
|
||||
|
||||
@@ -356,6 +354,8 @@ void SerumAltAudioProcessor::setStateInformation (const void* data, int sizeInBy
|
||||
engine.getMatrix().fromValueTree (state.getChildWithName ("MODMATRIX"));
|
||||
engine.getMacros().fromValueTree (state.getChildWithName ("MACROS"));
|
||||
restoreLfoShapesFromState (state);
|
||||
// Loading a saved project is a patch change too: start from clean state.
|
||||
engine.requestReset();
|
||||
|
||||
const double savedProgram = (double) state.getProperty ("currentProgram", 0);
|
||||
currentProgram.store (std::isfinite (savedProgram)
|
||||
@@ -406,7 +406,7 @@ void SerumAltAudioProcessor::restoreLfoShapesFromState (const juce::ValueTree& s
|
||||
continue;
|
||||
const double savedIndex = (double) lfo.getProperty ("index", -1);
|
||||
if (! std::isfinite (savedIndex) || savedIndex < 0.0 || savedIndex >= kNumLfos
|
||||
|| std::floor (savedIndex) != savedIndex)
|
||||
|| ! isWholeNumber (savedIndex))
|
||||
continue;
|
||||
const int index = (int) savedIndex;
|
||||
if (restored[(size_t) index])
|
||||
|
||||
@@ -30,4 +30,4 @@ pr.params = { { "oscAUnison", 0.4f } };
|
||||
pr.params = { p (ids::oscAUnison, unison (7)) };
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -31,4 +31,4 @@ g.setColour (juce::Colour (0xff4fc3f7));
|
||||
g.setColour (theme::accent);
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -3,12 +3,8 @@
|
||||
namespace serum
|
||||
{
|
||||
|
||||
void SubOscillator::noteOn (double freqHz, int octaveShift)
|
||||
void SubOscillator::noteOn()
|
||||
{
|
||||
// octaveShift: -2, -1 or 0 (from the subOct param).
|
||||
const double mult = (octaveShift == -2) ? 0.25 : (octaveShift == -1) ? 0.5 : 1.0;
|
||||
(void) freqHz;
|
||||
(void) mult;
|
||||
phase = 0.0;
|
||||
}
|
||||
|
||||
@@ -18,14 +14,14 @@ void SubOscillator::processAdd (double freqHz, int shape, float level, float& ou
|
||||
return;
|
||||
|
||||
// The octave shift is baked into freqHz by the voice; here we just phase-accumulate.
|
||||
const double inc = kTwoPi * freqHz / sr;
|
||||
const double inc = juce::MathConstants<double>::twoPi * freqHz / sr;
|
||||
phase += inc;
|
||||
phase -= std::floor (phase * (1.0 / kTwoPi)) * kTwoPi;
|
||||
phase -= std::floor (phase * (1.0 / juce::MathConstants<double>::twoPi)) * juce::MathConstants<double>::twoPi;
|
||||
|
||||
float s = 0.0f;
|
||||
if (shape == (int) SubShape::Triangle)
|
||||
{
|
||||
const float p = (float) (phase * (1.0 / kTwoPi));
|
||||
const float p = (float) (phase * (1.0 / juce::MathConstants<double>::twoPi));
|
||||
s = 1.0f - 4.0f * std::abs (p - 0.5f);
|
||||
}
|
||||
else
|
||||
|
||||
@@ -15,7 +15,7 @@ public:
|
||||
void prepare (double sampleRate) { sr = sampleRate; phase = 0.0; }
|
||||
void reset() { phase = 0.0; }
|
||||
|
||||
void noteOn (double freqHz, int octaveShift);
|
||||
void noteOn();
|
||||
|
||||
// Accumulate into out (mono; the caller pans/levels it).
|
||||
void processAdd (double freqHz, int shape, float level, float& out) noexcept;
|
||||
@@ -23,7 +23,6 @@ public:
|
||||
private:
|
||||
double sr = 44100.0;
|
||||
double phase = 0.0;
|
||||
static constexpr double kTwoPi = 6.28318530717958647692;
|
||||
};
|
||||
|
||||
} // namespace serum
|
||||
+18
-6
@@ -17,7 +17,7 @@ void SynthVoice::prepare (double sampleRate, int maxBlockSize)
|
||||
oscB.prepare (sampleRate);
|
||||
sub.prepare (sampleRate);
|
||||
noise.prepare (sampleRate);
|
||||
filters.prepare (sampleRate, maxBlockSize);
|
||||
filters.prepare (sampleRate);
|
||||
for (auto& e : env)
|
||||
e.prepare (sampleRate);
|
||||
scratch.setSize (2, maxBlockSize, false, false, true);
|
||||
@@ -55,7 +55,7 @@ void SynthVoice::noteOn (int noteNumber, float velocity01, double freqHz, juce::
|
||||
juce::Random rng (noteSeed);
|
||||
noteRandom = rng.nextFloat();
|
||||
|
||||
sub.noteOn (freqHz, -1);
|
||||
sub.noteOn();
|
||||
noise.noteOn (noteSeed);
|
||||
|
||||
for (auto& e : env)
|
||||
@@ -71,8 +71,20 @@ void SynthVoice::noteOff()
|
||||
e.noteOff();
|
||||
}
|
||||
|
||||
float SynthVoice::getModulationValue (ModSource source) const noexcept
|
||||
{
|
||||
if (source >= ModSource::Env1 && source <= ModSource::Env4)
|
||||
return env[(size_t) ((int) source - (int) ModSource::Env1)].getValue();
|
||||
if (source == ModSource::Velocity) return velocity;
|
||||
if (source == ModSource::Note) return clampF ((float) note / 127.0f, 0.0f, 1.0f);
|
||||
if (source == ModSource::Random) return noteRandom;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderContext& ctx) noexcept
|
||||
{
|
||||
jassert (numSamples <= scratch.getNumSamples());
|
||||
|
||||
if (! active || numSamples <= 0)
|
||||
return;
|
||||
|
||||
@@ -118,14 +130,14 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC
|
||||
float v = src[(int) c.source];
|
||||
if (c.bipolar && ! isBipolarSource (c.source))
|
||||
v = v * 2.0f - 1.0f;
|
||||
mod[(int) c.target] += v * c.depth;
|
||||
mod[(size_t) c.target] += v * c.depth;
|
||||
}
|
||||
}
|
||||
if (ctx.macros != nullptr)
|
||||
{
|
||||
for (int m = 0; m < kNumMacros; ++m)
|
||||
for (const auto& a : ctx.macros->assignments[(size_t) m])
|
||||
mod[(int) a.target] += ctx.macroValues[m] * a.depth;
|
||||
mod[(size_t) a.target] += ctx.macroValues[m] * a.depth;
|
||||
}
|
||||
|
||||
// 5. Modulated oscillator parameters.
|
||||
@@ -157,8 +169,8 @@ void SynthVoice::render (float* outL, float* outR, int numSamples, const RenderC
|
||||
// without resetting existing phases when modulated at control rate.
|
||||
if (oscillatorsNeedNoteOn)
|
||||
{
|
||||
oscA.noteOn (freqA, a, seed);
|
||||
oscB.noteOn (freqB, b, seed + 1);
|
||||
oscA.noteOn (a, seed);
|
||||
oscB.noteOn (b, seed + 1);
|
||||
oscillatorsNeedNoteOn = false;
|
||||
}
|
||||
else
|
||||
|
||||
@@ -72,6 +72,7 @@ public:
|
||||
bool isReleased() const noexcept { return released; }
|
||||
int getNote() const noexcept { return note; }
|
||||
juce::uint64 getNoteId() const noexcept { return noteId; }
|
||||
float getModulationValue (ModSource source) const noexcept;
|
||||
|
||||
private:
|
||||
Oscillator oscA, oscB;
|
||||
|
||||
@@ -37,4 +37,4 @@ if (! ok) ++failures;
|
||||
return failures == 0 ? 0 : 1;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
+121
-51
@@ -9,6 +9,8 @@
|
||||
// ===========================================================================
|
||||
namespace
|
||||
{
|
||||
const int notes[3] = { 48, 60, 67 };
|
||||
|
||||
bool hasNaN (const juce::AudioBuffer<float>& b)
|
||||
{
|
||||
for (int ch = 0; ch < b.getNumChannels(); ++ch)
|
||||
@@ -32,6 +34,36 @@ namespace
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// Plays three notes through the current preset, releases them partway, and
|
||||
// reports whether the render stayed finite and non-silent overall.
|
||||
bool runPreset (serum::SerumAltAudioProcessor& processor, const juce::String& label, double maxPeakFloor)
|
||||
{
|
||||
juce::MidiBuffer midi;
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
|
||||
|
||||
juce::AudioBuffer<float> buffer (2, 512);
|
||||
float maxPeak = 0.0f;
|
||||
bool nan = false;
|
||||
|
||||
for (int block = 0; block < 240; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
if (block == 180)
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOff (1, n), 0);
|
||||
nan = nan || hasNaN (buffer);
|
||||
maxPeak = std::max (maxPeak, peak (buffer));
|
||||
}
|
||||
|
||||
const bool ok = ! nan && maxPeak > (float) maxPeakFloor;
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [" << label << "]"
|
||||
<< " peak=" << maxPeak << (nan ? " <NaN!>" : "") << "\n";
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
@@ -44,64 +76,18 @@ int main()
|
||||
const int numPresets = processor.getNumPrograms();
|
||||
int failures = 0;
|
||||
|
||||
const int notes[3] = { 48, 60, 67 };
|
||||
|
||||
// Default (init) preset.
|
||||
{
|
||||
juce::MidiBuffer midi;
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
|
||||
|
||||
juce::AudioBuffer<float> buffer (2, 512);
|
||||
float maxPeak = 0.0f;
|
||||
bool nan = false;
|
||||
|
||||
for (int block = 0; block < 240; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
if (block == 180)
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOff (1, n), 0);
|
||||
nan = nan || hasNaN (buffer);
|
||||
maxPeak = std::max (maxPeak, peak (buffer));
|
||||
}
|
||||
|
||||
const bool ok = ! nan && maxPeak > 0.001f;
|
||||
if (! ok) ++failures;
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [Init] peak=" << maxPeak << (nan ? " <NaN!>" : "") << "\n";
|
||||
}
|
||||
if (! runPreset (processor, "Init", 0.001))
|
||||
++failures;
|
||||
|
||||
// Every factory preset.
|
||||
for (int preset = 0; preset < numPresets; ++preset)
|
||||
{
|
||||
processor.setCurrentProgram (preset);
|
||||
const juce::String name = processor.getProgramName (preset);
|
||||
|
||||
juce::MidiBuffer midi;
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
|
||||
|
||||
juce::AudioBuffer<float> buffer (2, 512);
|
||||
float maxPeak = 0.0f;
|
||||
bool nan = false;
|
||||
|
||||
for (int block = 0; block < 240; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
if (block == 180)
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOff (1, n), 0);
|
||||
nan = nan || hasNaN (buffer);
|
||||
maxPeak = std::max (maxPeak, peak (buffer));
|
||||
}
|
||||
|
||||
const bool ok = ! nan && maxPeak > 0.001f;
|
||||
if (! ok) ++failures;
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [" << processor.getProgramName (preset) << "]"
|
||||
<< " peak=" << maxPeak << (nan ? " <NaN!>" : "") << "\n";
|
||||
if (! runPreset (processor, name, 0.001))
|
||||
++failures;
|
||||
}
|
||||
|
||||
// RAVE toggle + parameter churn while playing (no crash / NaN).
|
||||
@@ -129,6 +115,90 @@ int main()
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [RAVE toggle / param churn]" << (nan ? " <NaN!>" : "") << "\n";
|
||||
}
|
||||
|
||||
// FX stability: a preset change selects an FX type and its parameters, so
|
||||
// every unit has to survive the extremes of its own parameter range. A unit
|
||||
// that lets its state run away keeps producing inf/NaN, which is heard as
|
||||
// grainy audio that gets no better.
|
||||
{
|
||||
const char* const typeLabels[] = { "Hyper", "Chorus", "Flanger", "Phaser",
|
||||
"Distortion", "EQ", "Compressor", "Delay", "Reverb" };
|
||||
|
||||
for (int type = 1; type <= 9; ++type)
|
||||
{
|
||||
processor.setCurrentProgram (0);
|
||||
auto set = [&processor] (const char* id, float value)
|
||||
{
|
||||
if (auto* p = processor.parameters.getParameter (id))
|
||||
p->setValueNotifyingHost (value);
|
||||
};
|
||||
set ("fx1Type", (float) type / 9.0f);
|
||||
set ("fx1Mix", 1.0f);
|
||||
set ("fx1P1", 1.0f);
|
||||
set ("fx1P2", 1.0f);
|
||||
set ("fx1P3", 1.0f);
|
||||
set ("fx1P4", 1.0f);
|
||||
|
||||
juce::MidiBuffer midi;
|
||||
midi.addEvent (juce::MidiMessage::noteOn (1, 60, (juce::uint8) 100), 0);
|
||||
|
||||
juce::AudioBuffer<float> buffer (2, 512);
|
||||
bool nan = false;
|
||||
for (int block = 0; block < 120; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
nan = nan || hasNaN (buffer);
|
||||
}
|
||||
|
||||
const bool ok = ! nan;
|
||||
if (! ok) ++failures;
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [FX " << typeLabels[type - 1]
|
||||
<< " at full parameter range]" << (nan ? " <NaN!>" : "") << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// A preset change starts from clean DSP state: the tail of the preset being
|
||||
// left behind must not bleed into the one being loaded.
|
||||
{
|
||||
juce::AudioBuffer<float> buffer (2, 512);
|
||||
processor.setCurrentProgram (9); // Glass Bell, reverb mix 0.6
|
||||
|
||||
juce::MidiBuffer midi;
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOn (1, n, (juce::uint8) 100), 0);
|
||||
for (int block = 0; block < 40; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
}
|
||||
for (int n : notes)
|
||||
midi.addEvent (juce::MidiMessage::noteOff (1, n), 0);
|
||||
for (int block = 0; block < 10; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
processor.processBlock (buffer, midi);
|
||||
midi.clear();
|
||||
}
|
||||
const float tail = peak (buffer);
|
||||
|
||||
processor.setCurrentProgram (0); // the change under test
|
||||
float residual = 0.0f;
|
||||
for (int block = 0; block < 2; ++block)
|
||||
{
|
||||
buffer.clear();
|
||||
juce::MidiBuffer empty;
|
||||
processor.processBlock (buffer, empty);
|
||||
residual = std::max (residual, peak (buffer));
|
||||
}
|
||||
|
||||
const bool ok = tail > 0.01f && residual < 1.0e-4f;
|
||||
if (! ok) ++failures;
|
||||
std::cout << (ok ? "PASS" : "FAIL") << " [preset change clears the previous patch]"
|
||||
<< " tail=" << tail << " residual=" << residual << "\n";
|
||||
}
|
||||
|
||||
std::cout << "\n" << (failures == 0 ? "ALL QA CHECKS PASSED" : "QA FAILURES DETECTED")
|
||||
<< " (" << failures << " failures)\n";
|
||||
return failures == 0 ? 0 : 1;
|
||||
|
||||
+6
-12
@@ -18,7 +18,6 @@ void Wavetable::buildHarmonic (const juce::String& n, HarmonicAmpFn ampFn, int n
|
||||
frames.assign (kFrames, std::vector<float> (kTableSize, 0.0f));
|
||||
|
||||
const float invN = 1.0f / (float) kTableSize;
|
||||
constexpr double twoPi = 6.28318530717958647692;
|
||||
|
||||
for (int f = 0; f < kFrames; ++f)
|
||||
{
|
||||
@@ -36,8 +35,8 @@ void Wavetable::buildHarmonic (const juce::String& n, HarmonicAmpFn ampFn, int n
|
||||
const float a = ampFn (f, h);
|
||||
const double m = std::abs ((double) a);
|
||||
mag[(size_t) h] = m;
|
||||
phase[(size_t) h] = (a < 0.0f) ? twoPi * 0.5 : 0.0; // sign -> 0 or pi/... using sine base
|
||||
const double ang = twoPi * (double) h * (double) invN;
|
||||
phase[(size_t) h] = (a < 0.0f) ? juce::MathConstants<double>::twoPi * 0.5 : 0.0; // sign -> 0 or pi/... using sine base
|
||||
const double ang = juce::MathConstants<double>::twoPi * (double) h * (double) invN;
|
||||
cosStep[(size_t) h] = std::cos (ang);
|
||||
sinStep[(size_t) h] = std::sin (ang);
|
||||
maxAmp = std::max (maxAmp, m);
|
||||
@@ -153,7 +152,6 @@ void WavetableLibrary::buildTable (int index) const
|
||||
namespace
|
||||
{
|
||||
inline float normT (int frame) { return (float) frame / 255.0f; }
|
||||
constexpr float kPi = 3.14159265358979323846f;
|
||||
}
|
||||
|
||||
// sine -> saw morph
|
||||
@@ -178,8 +176,8 @@ Wavetable WavetableLibrary::makeSawPwm()
|
||||
{
|
||||
const float t = normT (f);
|
||||
const float duty = 0.08f + 0.42f * t; // 8% -> 50%
|
||||
const float a = std::sin (kPi * (float) h * duty);
|
||||
return (2.0f / ((float) h * kPi)) * a;
|
||||
const float a = std::sin (juce::MathConstants<float>::pi * (float) h * duty);
|
||||
return (2.0f / ((float) h * juce::MathConstants<float>::pi)) * a;
|
||||
}, 48);
|
||||
return wt;
|
||||
}
|
||||
@@ -192,7 +190,7 @@ Wavetable WavetableLibrary::makeSquareSync()
|
||||
{
|
||||
const float t = normT (f);
|
||||
const float ratio = 1.0f + 3.0f * t;
|
||||
const float comb = 0.5f + 0.5f * std::cos (2.0f * kPi * (float) h * ratio);
|
||||
const float comb = 0.5f + 0.5f * std::cos (2.0f * juce::MathConstants<float>::pi * (float) h * ratio);
|
||||
return (1.0f / std::pow ((float) h, 0.8f)) * comb;
|
||||
}, 48);
|
||||
return wt;
|
||||
@@ -211,7 +209,7 @@ Wavetable WavetableLibrary::makeTriangleFold()
|
||||
{
|
||||
const int k = (h - 1) / 2;
|
||||
const float sign = ((k & 1) == 0) ? 1.0f : -1.0f;
|
||||
tri = sign * 8.0f / (kPi * kPi * (float) (h * h));
|
||||
tri = sign * 8.0f / (juce::MathConstants<float>::pi * juce::MathConstants<float>::pi * (float) (h * h));
|
||||
}
|
||||
const float saw = 1.0f / (float) h;
|
||||
return tri + (saw - tri) * t;
|
||||
@@ -235,15 +233,11 @@ Wavetable WavetableLibrary::makeVowel()
|
||||
float from[3], to[3];
|
||||
if (t < 0.5f)
|
||||
{
|
||||
const float u = t * 2.0f;
|
||||
for (int k = 0; k < 3; ++k) { from[k] = aa[k]; to[k] = ee[k]; }
|
||||
(void) u;
|
||||
}
|
||||
else
|
||||
{
|
||||
const float u = (t - 0.5f) * 2.0f;
|
||||
for (int k = 0; k < 3; ++k) { from[k] = ee[k]; to[k] = ii[k]; }
|
||||
(void) u;
|
||||
}
|
||||
|
||||
float tt = (t < 0.5f) ? t * 2.0f : (t - 0.5f) * 2.0f;
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
## Related source files
|
||||
|
||||
This directory contains guidance; the implementation remains in `../Engine.h`
|
||||
and `../Engine.cpp`. The preparation rules below are requirements for changes;
|
||||
current buffer resizing and lazy wavetable issues are recorded in the root AGENT.md.
|
||||
and `../Engine.cpp`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Own the voice pool, LFOs, wavetable library, FX rack, matrix and macros by value.
|
||||
- Read every APVTS value through the null-guarding `v()` helper.
|
||||
- Build the `RenderContext` once per block and pass it to voices as `const&`.
|
||||
- Size `mixBuffer` once in `prepare()` and only `clear()` it per block.
|
||||
- Prebuild wavetables in `prepare()` before the first render.
|
||||
- Bind APVTS pointers in `prepare()`; `v()` reads captured values without constructing parameter-name strings.
|
||||
- Build the `RenderContext` once per host block and refresh its modulation inputs for each render segment.
|
||||
- Render up to 64 samples at a time, splitting at MIDI event offsets and respecting prepared buffer capacity.
|
||||
- Size `mixBuffer` once in `prepare()`; use non-owning views for shorter segments.
|
||||
- Prebuild wavetables in the Engine constructor, before either GUI painting or audio rendering can read them.
|
||||
- Clamp the final output with the soft limiter.
|
||||
- Never call `setSize` on any buffer inside `processBlock`.
|
||||
|
||||
@@ -33,16 +33,18 @@ void processBlock (...)
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: size once, clear per block
|
||||
// GOOD: size once, then wrap a non-owning view per segment
|
||||
void prepare (double sr, int blockSize)
|
||||
{
|
||||
mixBuffer.setSize (2, blockSize, false, false, true);
|
||||
}
|
||||
void processBlock (...)
|
||||
{
|
||||
mixBuffer.clear();
|
||||
// No allocation; the view only borrows mixBuffer's channels.
|
||||
juce::AudioBuffer<float> block (mixBuffer.getArrayOfWritePointers(), 2, segmentLength);
|
||||
block.clear();
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -15,20 +15,20 @@ directory:
|
||||
- Require a cap on connection and assignment counts (`kMaxConnections`, `kMaxAssignments`).
|
||||
- Serialise and restore state through `juce::ValueTree` so presets survive save and load.
|
||||
- Never accept a `ModTarget::None` connection; reject it and return false.
|
||||
- Use `ids::` for every APVTS parameter reference in RaveController.
|
||||
- RaveController applies a non-destructive boost to render settings. It does not write APVTS parameters or keep a restoration snapshot.
|
||||
- Keep the existing RAVEButton filenames unless a rename is explicitly in scope.
|
||||
|
||||
## GUI/audio state
|
||||
|
||||
The current public connection and assignment vectors are shared mutable state.
|
||||
GUI writes can race with audio-thread iteration. A mutex taken only by the writer
|
||||
does not protect an unlocked reader, and taking a blocking mutex in the audio
|
||||
callback conflicts with the real-time requirements.
|
||||
Hold `Engine::getControlLock()` while reading or editing the control-side matrix,
|
||||
macro assignments, or LFO shapes. The audio thread tries that lock once per block
|
||||
and copies into reserved audio-side storage. If the lock is busy, it keeps the
|
||||
previous settings and continues rendering. Never iterate the control-side vectors
|
||||
from the render path.
|
||||
|
||||
When fixing this path, publish a stable snapshot or use a bounded thread-safe
|
||||
handoff. Keep allocation and snapshot reclamation off the audio thread. Validate
|
||||
reader and writer lifetimes together, including preset and state restore.
|
||||
Master and FX-mix modulation use global sources directly. Per-voice sources for
|
||||
those destinations come from the newest active voice.
|
||||
|
||||
If `addConnection` or `addAssignment` returns false, handle the failed insert.
|
||||
|
||||
This file refines the repository-root AGENT.md for the related sources.
|
||||
This file refines the repository-root AGENTS.md for the related sources.
|
||||
@@ -9,6 +9,10 @@ and `../Params.cpp`.
|
||||
|
||||
- Use the `ids::` namespace for every APVTS parameter ID string.
|
||||
- Require one `ids::` entry per audio parameter, and one entry only.
|
||||
- Reach indexed parameters through the `paramIds::` arrays in `../Params.h`, which pair each
|
||||
ID with its slot: `paramIds::oscillators`, `paramIds::envAttack`, `paramIds::lfoRate`,
|
||||
`paramIds::fxType`, `paramIds::macros` and their siblings. Use a single `ids::` entry only
|
||||
when the parameter has no index.
|
||||
- Use `enum class` for every enum. Give each a trailing `Count` enumerator.
|
||||
- Use `inline constexpr` for parameter ID strings and `k`-prefixed integer constants.
|
||||
- Keep all 0..1 to physical unit conversions in the `maps::` namespace.
|
||||
@@ -36,4 +40,4 @@ if (auto* p = apvts.getParameter (ids::f1Cutoff))
|
||||
return p->getValue();
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -13,7 +13,10 @@ This directory contains guidance; the implementation remains in
|
||||
- Guard `getRawParameterValue` and `getParameter` results before dereferencing them.
|
||||
- Return silently on null or invalid state XML and invalid ValueTrees.
|
||||
- Use raw `new` only for JUCE-owned objects (`createEditor`, `createPluginFilter`).
|
||||
- Prefer const accessors over the public `parameters`, `engine` and `rave` fields for new code.
|
||||
- Hold the engine control lock across preset and state changes; preserve parameter IDs and normalized values for saved-project compatibility.
|
||||
- Factory presets reset sound parameters before applying overrides, while preserving UI scale.
|
||||
- Save one copy of each custom state section. LFO shape arrays are stored as JSON text inside XML.
|
||||
- RAVE is an ordinary parameter that enables a render-time boost; it does not rewrite other parameters.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
@@ -38,4 +41,4 @@ if (! state.isValid())
|
||||
return;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
@@ -3,8 +3,7 @@
|
||||
## Related source files
|
||||
|
||||
This directory contains guidance. All files listed below remain in the parent
|
||||
`Source/` directory. The preparation rules below are requirements for changes,
|
||||
not a claim that lazy wavetable allocation has already been fixed.
|
||||
`Source/` directory.
|
||||
|
||||
- Wavetable.h, Wavetable.cpp
|
||||
- Oscillator.h, Oscillator.cpp
|
||||
@@ -26,10 +25,11 @@ not a claim that lazy wavetable allocation has already been fixed.
|
||||
- Use `(size_t)` casts when indexing containers with int loop variables.
|
||||
- Pass global per-block state into voices as a `const RenderContext&`.
|
||||
- Never allocate memory in the audio path.
|
||||
- Keep oscillator phase when changing held-note parameters. LFO phase offsets are separate from the running phase.
|
||||
|
||||
## IF-THEN
|
||||
|
||||
- IF a wavetable is read in the render path THEN build all tables in `prepare()` by calling `WavetableLibrary::prebuild()` before the first block.
|
||||
- Wavetables are prebuilt during Engine construction. Audio and GUI code read them without triggering generation.
|
||||
- IF a parameter is read every block THEN snapshot it into a struct (`OscParams`, `RenderContext`) instead of reading the APVTS per sample.
|
||||
|
||||
## Examples
|
||||
@@ -40,8 +40,8 @@ const Wavetable& wt = library.getTable (wave);
|
||||
```
|
||||
|
||||
```cpp
|
||||
// GOOD: build once at prepare time, then only read const references
|
||||
library.prebuild(); // in prepareToPlay
|
||||
// GOOD: build once in the Engine constructor, then only read const references
|
||||
library.prebuild(); // in the Engine constructor
|
||||
const Wavetable& wt = library.getTable (wave); // lookup, no allocation
|
||||
```
|
||||
|
||||
@@ -55,4 +55,4 @@ const double step = 6.28318530717958647692 * freq / sr;
|
||||
const double step = juce::MathConstants<double>::twoPi * freq / sr;
|
||||
```
|
||||
|
||||
This file overrides /AGENT.md where they conflict.
|
||||
This file overrides /AGENTS.md where they conflict.
|
||||
Reference in new issue
Block a user