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:
biggy committed 2026-10-06 08:50:45 +02:00
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.
+5 -5
View File
@@ -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);
+3 -3
View File
@@ -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;
+6 -2
View File
@@ -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));
+7 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+9 -7
View File
@@ -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
View File
@@ -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;
+1 -3
View File
@@ -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.
+6 -3
View File
@@ -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);
+8 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+47
View File
@@ -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
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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);
};
-156
View File
@@ -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
+2 -2
View File
@@ -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);
+13 -5
View File
@@ -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);
}
+2 -1
View File
@@ -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
View File
@@ -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);
+9
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+6
View File
@@ -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
View File
@@ -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()
-3
View File
@@ -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
View File
@@ -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.
+4 -8
View File
@@ -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
+1 -2
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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.