chromaflock/Source/PluginEditor.cpp
2026-07-15 18:29:49 +02:00

1271 lines
54 KiB
C++

#include "PluginEditor.h"
#include <BinaryData.h>
void KnobLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height,
float sliderPos, float rotaryStartAngle,
float rotaryEndAngle, juce::Slider& slider) {
auto bounds = juce::Rectangle<int>(x, y, width, height).toFloat();
float labelH = 20.0f;
auto dialBounds = bounds.withTrimmedTop(labelH);
auto radius = juce::jmin(dialBounds.getWidth(), dialBounds.getHeight()) / 2.0f - 4.0f;
auto centreX = dialBounds.getCentreX();
auto centreY = dialBounds.getCentreY();
auto rx = centreX - radius;
auto ry = centreY - radius;
auto rw = radius * 2.0f;
auto angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle);
// Drop shadow
g.setColour(juce::Colour(0x40000000));
g.fillEllipse(rx + 2.0f, ry + 3.0f, rw, rw);
// Outer bevel (dark bottom-right, light top-left)
auto bevelPath = juce::Path();
bevelPath.addEllipse(rx, ry, rw, rw);
// Knob body gradient — light top-left to dark bottom-right for 3D bulge
juce::ColourGradient bodyGrad(juce::Colour(0xb3404040), rx, ry,
juce::Colour(0xb31a1a1a), rx + rw, ry + rw, true);
g.setGradientFill(bodyGrad);
g.fillEllipse(rx, ry, rw, rw);
// Soft inner shadow ring (bottom-right dark edge, subtle)
g.setColour(juce::Colour(0x18000000));
auto shadowArc = juce::Path();
float innerR = radius - 2.0f;
shadowArc.addCentredArc(centreX, centreY, innerR, innerR, 0.0f,
0.5f, 2.7f, true);
g.strokePath(shadowArc, juce::PathStrokeType(1.5f));
// Outer rim
g.setColour(juce::Colour(0xff555555));
g.drawEllipse(rx, ry, rw, rw, 1.5f);
// Pointer line with glow — green (left) → yellow (mid) → red (right)
auto indicatorColour = juce::Colour::fromHSV(0.33f * (1.0f - sliderPos), 0.85f, 0.9f, 1.0f);
auto glowColour = juce::Colour::fromHSV(0.33f * (1.0f - sliderPos), 0.85f, 0.9f, 0.4f);
g.setColour(glowColour);
juce::Path pointerGlow;
pointerGlow.addRoundedRectangle(-2.5f, -radius + 9, 5.0f, radius * 0.5f, 2.0f);
g.fillPath(pointerGlow, juce::AffineTransform::rotation(angle).translated(centreX, centreY));
g.setColour(indicatorColour);
juce::Path pointer;
pointer.addRoundedRectangle(-1.5f, -radius + 9, 3.0f, radius * 0.5f, 1.5f);
g.fillPath(pointer, juce::AffineTransform::rotation(angle).translated(centreX, centreY));
// Label above knob
auto name = slider.getName();
if (name.isNotEmpty()) {
g.setColour(juce::Colour(0xff999999));
g.setFont(juce::Font(13.0f).boldened());
g.drawText(name, bounds.getX(), bounds.getY(), bounds.getWidth(), labelH,
juce::Justification::centredBottom);
}
}
void ComboBoxLookAndFeel::drawComboBox(juce::Graphics& g, int width, int height,
bool isButtonDown, int /*buttonX*/, int /*buttonY*/,
int /*buttonW*/, int /*buttonH*/, juce::ComboBox& /*box*/) {
auto bounds = juce::Rectangle<int>(0, 0, width, height).toFloat();
// Drop shadow
g.setColour(juce::Colour(0x40000000));
g.fillRoundedRectangle(bounds.getX() + 1.5f, bounds.getY() + 2.0f,
bounds.getWidth(), bounds.getHeight(), 4.0f);
// Body gradient — spotlight at 88° from horizontal (near-vertical, subtle tilt)
float spotlightAngle = 88.0f * juce::MathConstants<float>::pi / 180.0f;
float sdx = std::cos(spotlightAngle);
float sdy = std::sin(spotlightAngle);
float scx = bounds.getCentreX();
float scy = bounds.getCentreY();
float slen = std::hypot(bounds.getWidth(), bounds.getHeight()) * 0.5f;
juce::ColourGradient bodyGrad(juce::Colour(0xb3404040), scx - sdx * slen, scy - sdy * slen,
juce::Colour(0xb31a1a1a), scx + sdx * slen, scy + sdy * slen, true);
g.setGradientFill(bodyGrad);
g.fillRoundedRectangle(bounds, 4.0f);
// Top highlight edge
g.setColour(juce::Colour(0x30ffffff));
g.drawRoundedRectangle(bounds.getX() + 0.5f, bounds.getY() + 0.5f,
bounds.getWidth() - 1.0f, bounds.getHeight() - 1.0f, 4.0f, 1.0f);
// Bottom shadow edge
g.setColour(juce::Colour(0x30000000));
g.drawLine(bounds.getX() + 4.0f, bounds.getBottom() - 0.5f,
bounds.getRight() - 4.0f, bounds.getBottom() - 0.5f, 1.0f);
// Outline
g.setColour(juce::Colour(0xff555555));
g.drawRoundedRectangle(bounds, 4.0f, 1.0f);
// Arrow (gold triangle on right)
float arrowSize = 6.0f;
float arrowX = bounds.getRight() - 16.0f;
float arrowY = bounds.getCentreY();
juce::Path arrow;
arrow.addTriangle(arrowX, arrowY - arrowSize / 2,
arrowX + arrowSize, arrowY,
arrowX, arrowY + arrowSize / 2);
g.setColour(juce::Colour(0xffccaa44));
g.fillPath(arrow);
}
void ComboBoxLookAndFeel::drawPopupMenuItem(juce::Graphics& g, const juce::Rectangle<int>& area,
bool isSeparator, bool /*isActive*/,
bool isHighlighted, bool isTicked,
bool /*hasSubMenu*/, const juce::String& text,
const juce::String& /*shortcutKeyText*/,
const juce::Drawable* /*icon*/,
const juce::Colour* /*textColour*/) {
if (isSeparator) {
g.setColour(juce::Colour(0xff444444));
g.drawLine(area.getX() + 8.0f, area.getCentreY(),
area.getRight() - 8.0f, area.getCentreY(), 1.0f);
return;
}
// Persistent highlight for the currently selected (ticked) entry or section
if (isTicked && !isHighlighted) {
g.setColour(juce::Colour(0xffccaa44).withAlpha(0.3f));
g.fillRoundedRectangle(area.reduced(2).toFloat(), 3.0f);
}
if (isHighlighted) {
g.setColour(juce::Colour(0xffccaa44));
g.fillRoundedRectangle(area.reduced(2).toFloat(), 3.0f);
}
g.setColour(isHighlighted ? juce::Colour(0xff1a1a1a)
: (isTicked ? juce::Colour(0xffccaa44) : juce::Colour(0xffcccccc)));
g.setFont(juce::Font(13.0f));
g.drawText(text, area.reduced(8, 0), juce::Justification::centredLeft, true);
}
// --- VU Meter ---
void VuMeter::paint(juce::Graphics& g) {
auto bounds = getLocalBounds().toFloat().reduced(0.5f);
juce::ColourGradient bgGrad(juce::Colour(0xBB222222), bounds.getCentreX(), bounds.getY(),
juce::Colour(0xBB111111), bounds.getCentreX(), bounds.getBottom(), false);
g.setGradientFill(bgGrad);
g.fillRoundedRectangle(bounds, 9.0f);
float level = processor.getRmsLevel();
float clamped = juce::jlimit(0.0f, 1.0f, level);
float barH = bounds.getHeight() * clamped;
auto barBounds = juce::Rectangle<float>(bounds.getX(), bounds.getBottom() - barH, bounds.getWidth(), barH);
auto innerBar = barBounds.reduced(1.0f);
{
juce::Graphics::ScopedSaveState saved(g);
juce::Path clipPath;
clipPath.addRoundedRectangle(bounds, 9.0f);
g.reduceClipRegion(clipPath);
g.setOpacity(0.7f);
juce::ColourGradient grad(juce::Colour(0xff00cc44), 0.0f, bounds.getBottom(),
juce::Colour(0xffcc2200), 0.0f, bounds.getY(), false);
g.setGradientFill(grad);
g.fillRoundedRectangle(innerBar, 2.0f);
}
g.setColour(juce::Colour(0xff333333));
g.drawRoundedRectangle(bounds, 9.0f, 1.0f);
g.setColour(juce::Colour(0xffccaa44));
g.setFont(juce::Font(13.0f).boldened());
g.drawText("VU", bounds.withBottom(bounds.getY() + 22.0f).translated(0, 6), juce::Justification::centred);
}
// --- Waveform Display ---
void WaveformDisplay::paint(juce::Graphics& g) {
auto bounds = getLocalBounds().toFloat().reduced(0.5f);
juce::ColourGradient bgGrad(juce::Colour(0xBB222222), bounds.getCentreX(), bounds.getY(),
juce::Colour(0xBB111111), bounds.getCentreX(), bounds.getBottom(), false);
g.setGradientFill(bgGrad);
g.fillRoundedRectangle(bounds, 9.0f);
g.setColour(juce::Colour(0xff333333));
g.drawRoundedRectangle(bounds, 9.0f, 1.0f);
g.setColour(juce::Colour(0xff333333));
g.drawLine(bounds.getX(), bounds.getCentreY(), bounds.getRight(), bounds.getCentreY(), 1.0f);
int writePos = processor.scopeWritePos.load(std::memory_order_acquire);
int bufSize = ChromaFlockProcessor::scopeBufferSize;
float w = bounds.getWidth();
float h = bounds.getHeight();
float midY = bounds.getY() + h * 0.5f;
juce::Path wavePath;
bool started = false;
for (int i = 0; i < bufSize; ++i) {
int idx = (writePos + i) % bufSize;
float x = bounds.getX() + (static_cast<float>(i) / static_cast<float>(bufSize)) * w;
float sample = processor.scopeBuffer[idx];
float y = midY - sample * h * 0.45f;
if (!started) {
wavePath.startNewSubPath(x, y);
started = true;
} else {
wavePath.lineTo(x, y);
}
}
juce::Path filledPath(wavePath);
filledPath.lineTo(bounds.getRight(), midY);
filledPath.lineTo(bounds.getX(), midY);
filledPath.closeSubPath();
{
juce::Graphics::ScopedSaveState saved(g);
juce::Path clipPath;
clipPath.addRoundedRectangle(bounds, 9.0f);
g.reduceClipRegion(clipPath);
g.setOpacity(0.7f);
juce::ColourGradient waveGrad(juce::Colour(0xff00ff88), 0.0f, bounds.getY(),
juce::Colour(0xff005522), 0.0f, bounds.getBottom(), false);
g.setGradientFill(waveGrad);
g.fillPath(filledPath);
g.setColour(juce::Colour(0xff00ff88).withAlpha(0.8f));
g.strokePath(wavePath, juce::PathStrokeType(1.5f));
}
g.setColour(juce::Colour(0xffccaa44));
g.setFont(juce::Font(13.0f).boldened());
g.drawText("WAVE", bounds.withBottom(bounds.getY() + 22.0f).translated(0, 6), juce::Justification::centred);
}
// --- Spectrum Analyzer ---
void SpectrumAnalyzer::paint(juce::Graphics& g) {
auto bounds = getLocalBounds().toFloat().reduced(0.5f);
juce::ColourGradient bgGrad(juce::Colour(0xBB222222), bounds.getCentreX(), bounds.getY(),
juce::Colour(0xBB111111), bounds.getCentreX(), bounds.getBottom(), false);
g.setGradientFill(bgGrad);
g.fillRoundedRectangle(bounds, 9.0f);
g.setColour(juce::Colour(0xff333333));
g.drawRoundedRectangle(bounds, 9.0f, 1.0f);
std::array<float, ChromaFlockProcessor::fftSize * 2> fftData{};
int writePos = processor.fftWritePos.load(std::memory_order_acquire);
int fftSize = ChromaFlockProcessor::fftSize;
for (int i = 0; i < fftSize; ++i) {
int idx = (writePos + i) % fftSize;
fftData[i] = processor.fftInput[idx];
}
for (int i = 0; i < fftSize; ++i) {
float window = 0.5f - 0.5f * std::cos(2.0f * 3.14159265f * static_cast<float>(i) / static_cast<float>(fftSize));
fftData[i] *= window;
}
fft->performFrequencyOnlyForwardTransform(fftData.data());
int numBars = 48;
float w = bounds.getWidth();
float h = bounds.getHeight() - 22.0f;
float barBottom = bounds.getBottom() - 2.0f;
float barW = w / static_cast<float>(numBars);
int maxBin = fftSize / 4;
juce::Graphics::ScopedSaveState savedClip(g);
juce::Path clipPath;
clipPath.addRoundedRectangle(bounds, 9.0f);
g.reduceClipRegion(clipPath);
for (int bar = 0; bar < numBars; ++bar) {
float t = static_cast<float>(bar) / static_cast<float>(numBars);
float tNext = static_cast<float>(bar + 1) / static_cast<float>(numBars);
int binStart = static_cast<int>(std::pow(t, 2.0f) * static_cast<float>(maxBin));
int binEnd = static_cast<int>(std::pow(tNext, 2.0f) * static_cast<float>(maxBin));
if (binEnd <= binStart) binEnd = binStart + 1;
if (binEnd > maxBin) binEnd = maxBin;
float mag = 0.0f;
int count = 0;
for (int b = binStart; b < binEnd; ++b) {
mag += fftData[b];
++count;
}
mag = count > 0 ? mag / static_cast<float>(count) : 0.0f;
mag = mag / static_cast<float>(fftSize);
mag = std::sqrt(mag) * 6.0f;
mag = juce::jlimit(0.0f, 1.0f, mag);
float barH = mag * h;
float x = bounds.getX() + static_cast<float>(bar) * barW;
float bw = barW - 2.0f;
float bx = x + 1.0f;
float by = barBottom - barH;
{
juce::Graphics::ScopedSaveState saved(g);
g.setOpacity(0.7f);
g.setColour(juce::Colour(0xff00ff88));
g.fillRect(bx, by, bw, barH);
}
}
g.setColour(juce::Colour(0xffccaa44));
g.setFont(juce::Font(13.0f).boldened());
g.drawText("SPECTRUM", bounds.withBottom(bounds.getY() + 22.0f).translated(0, 6), juce::Justification::centred);
}
// --- Patch LCD (green dot-matrix) ---
namespace {
struct Glyph { char c; unsigned char rows[7]; };
// 5x7 font; each byte is a row, bits 4..0 are columns left->right.
const Glyph font5x7[] = {
{' ', {0x00,0x00,0x00,0x00,0x00,0x00,0x00}},
{'-', {0x00,0x00,0x00,0x1F,0x00,0x00,0x00}},
{'A', {0x0E,0x11,0x11,0x1F,0x11,0x11,0x11}},
{'B', {0x1E,0x11,0x11,0x1E,0x11,0x11,0x1E}},
{'C', {0x0E,0x11,0x10,0x10,0x10,0x11,0x0E}},
{'D', {0x1E,0x11,0x11,0x11,0x11,0x11,0x1E}},
{'E', {0x1F,0x10,0x10,0x1E,0x10,0x10,0x1F}},
{'F', {0x1F,0x10,0x10,0x1E,0x10,0x10,0x10}},
{'G', {0x0E,0x11,0x10,0x17,0x11,0x11,0x0F}},
{'H', {0x11,0x11,0x11,0x1F,0x11,0x11,0x11}},
{'I', {0x0E,0x04,0x04,0x04,0x04,0x04,0x0E}},
{'J', {0x07,0x02,0x02,0x02,0x02,0x12,0x0C}},
{'K', {0x11,0x12,0x14,0x18,0x14,0x12,0x11}},
{'L', {0x10,0x10,0x10,0x10,0x10,0x10,0x1F}},
{'M', {0x11,0x1B,0x15,0x15,0x11,0x11,0x11}},
{'N', {0x11,0x11,0x19,0x15,0x13,0x11,0x11}},
{'O', {0x0E,0x11,0x11,0x11,0x11,0x11,0x0E}},
{'P', {0x1E,0x11,0x11,0x1E,0x10,0x10,0x10}},
{'Q', {0x0E,0x11,0x11,0x11,0x15,0x12,0x0D}},
{'R', {0x1E,0x11,0x11,0x1E,0x14,0x12,0x11}},
{'S', {0x0F,0x10,0x10,0x0E,0x01,0x01,0x1E}},
{'T', {0x1F,0x04,0x04,0x04,0x04,0x04,0x04}},
{'U', {0x11,0x11,0x11,0x11,0x11,0x11,0x0E}},
{'V', {0x11,0x11,0x11,0x11,0x11,0x0A,0x04}},
{'W', {0x11,0x11,0x11,0x15,0x15,0x1B,0x11}},
{'X', {0x11,0x11,0x0A,0x04,0x0A,0x11,0x11}},
{'Y', {0x11,0x11,0x0A,0x04,0x04,0x04,0x04}},
{'Z', {0x1F,0x01,0x02,0x04,0x08,0x10,0x1F}},
{'0', {0x0E,0x13,0x15,0x15,0x19,0x11,0x0E}},
{'1', {0x04,0x0C,0x04,0x04,0x04,0x04,0x0E}},
{'2', {0x0E,0x11,0x01,0x06,0x08,0x10,0x1F}},
{'3', {0x1F,0x02,0x04,0x02,0x01,0x11,0x0E}},
{'4', {0x02,0x06,0x0A,0x12,0x1F,0x02,0x02}},
{'5', {0x1F,0x10,0x1E,0x01,0x01,0x11,0x0E}},
{'6', {0x06,0x08,0x10,0x1E,0x11,0x11,0x0E}},
{'7', {0x1F,0x01,0x02,0x04,0x08,0x08,0x08}},
{'8', {0x0E,0x11,0x11,0x0E,0x11,0x11,0x0E}},
{'9', {0x0E,0x11,0x11,0x0F,0x01,0x02,0x0C}},
};
const unsigned char* getFontGlyph(char c) {
for (const auto& g : font5x7)
if (g.c == c) return g.rows;
return font5x7[0].rows; // space fallback
}
}
void PatchLCD::paint(juce::Graphics& g) {
auto b = getLocalBounds();
// Dark LCD panel
g.setColour(juce::Colour(0xcc061206));
g.fillRoundedRectangle(b.toFloat(), 3.0f);
g.setColour(juce::Colour(0xff114411));
g.drawRoundedRectangle(b.toFloat(), 3.0f, 1.0f);
const int pitch = 3;
const int glyphW = 5, glyphH = 7, glyphGap = 1;
// Faint "off" LED grid
g.setColour(juce::Colour(0xff0a200a));
for (int y = pitch / 2; y < b.getHeight(); y += pitch)
for (int x = pitch / 2; x < b.getWidth(); x += pitch)
g.fillRect(static_cast<float>(x) - 0.5f, static_cast<float>(y) - 0.5f, 1.0f, 1.0f);
int y0 = (b.getHeight() - glyphH * pitch) / 2;
int cx = 5;
int cy = y0;
if (cy < 0) cy = 0;
juce::String u = text.toUpperCase();
for (auto chr : u) {
const unsigned char* g7 = getFontGlyph(chr);
for (int row = 0; row < glyphH; ++row) {
unsigned char bits = g7[row];
for (int col = 0; col < glyphW; ++col) {
if (bits & (1 << (glyphW - 1 - col))) {
float px = static_cast<float>(cx + col * pitch + pitch / 2);
float py = static_cast<float>(cy + row * pitch + pitch / 2);
g.setColour(juce::Colour(0xff1a330a));
g.fillEllipse(px - pitch * 0.5f, py - pitch * 0.5f, pitch, pitch);
g.setColour(juce::Colour(0xff33ff66));
g.fillEllipse(px - pitch * 0.4f, py - pitch * 0.4f, pitch * 0.8f, pitch * 0.8f);
}
}
}
cx += (glyphW + glyphGap) * pitch;
if (cx > b.getWidth()) break;
}
}
MainContentComponent::MainContentComponent(ChromaFlockProcessor& p)
: processorRef(p), vuMeter(p), waveformDisplay(p), spectrumAnalyzer(p), pianoRoll(p) {
auto setupParam = [&](juce::Slider& knob, std::unique_ptr<SliderAttachment>& attach,
const juce::String& paramId, const juce::String& name) {
setupKnob(knob, name);
attach = std::make_unique<SliderAttachment>(processorRef.apvts, paramId, knob);
};
auto setupCB = [&](juce::ComboBox& box, std::unique_ptr<ComboBoxAttachment>& attach,
const juce::String& paramId, const juce::StringArray& items) {
setupCombo(box);
box.addItemList(items, 1);
attach = std::make_unique<ComboBoxAttachment>(processorRef.apvts, paramId, box);
};
setupLabel(osc1Label, "OSC 1");
setupCB(osc1WaveBox, osc1WaveAttach, "osc1Wave", {"Sine", "Saw", "Square", "Triangle", "Noise"});
setupParam(osc1OctKnob, osc1OctAttach, "osc1Oct", "OCT");
setupParam(osc1SemiKnob, osc1SemiAttach, "osc1Semi", "SEMI");
setupParam(osc1FineKnob, osc1FineAttach, "osc1Fine", "FINE");
setupParam(osc1LevelKnob, osc1LevelAttach, "osc1Level", "LEVEL");
setupLabel(osc2Label, "OSC 2");
setupCB(osc2WaveBox, osc2WaveAttach, "osc2Wave", {"Sine", "Saw", "Square", "Triangle", "Noise"});
setupParam(osc2OctKnob, osc2OctAttach, "osc2Oct", "OCT");
setupParam(osc2SemiKnob, osc2SemiAttach, "osc2Semi", "SEMI");
setupParam(osc2FineKnob, osc2FineAttach, "osc2Fine", "FINE");
setupParam(osc2LevelKnob, osc2LevelAttach, "osc2Level", "LEVEL");
setupParam(phaseOffsetKnob, phaseOffsetAttach, "phaseOffset", "PHASE");
setupLabel(filterLabel, "FILTER");
setupCB(filterTypeBox, filterTypeAttach, "filterType", {"LP 12dB", "LP 24dB", "Band Pass", "High Pass", "Notch"});
setupParam(filterCutoffKnob, filterCutoffAttach, "filterCutoff", "CUTOFF");
setupParam(filterResKnob, filterResAttach, "filterRes", "RES");
setupParam(filterEnvAmtKnob, filterEnvAmtAttach, "filterEnvAmt", "ENV AMT");
setupParam(keyTrackKnob, keyTrackAttach, "keyTrack", "KEY TRK");
setupLabel(envLabel, "AMP ENV");
setupParam(envAttackKnob, envAttackAttach, "envAttack", "ATTACK");
setupParam(envDecayKnob, envDecayAttach, "envDecay", "DECAY");
setupParam(envSustainKnob, envSustainAttach, "envSustain", "SUSTAIN");
setupParam(envReleaseKnob, envReleaseAttach, "envRelease", "RELEASE");
setupLabel(fEnvLabel, "FILTER ENV");
setupParam(fEnvAttackKnob, fEnvAttackAttach, "fEnvAttack", "ATTACK");
setupParam(fEnvDecayKnob, fEnvDecayAttach, "fEnvDecay", "DECAY");
setupParam(fEnvSustainKnob, fEnvSustainAttach, "fEnvSustain", "SUSTAIN");
setupParam(fEnvReleaseKnob, fEnvReleaseAttach, "fEnvRelease", "RELEASE");
setupLabel(globalLabel, "MASTER");
setupParam(panKnob, panAttach, "pan", "PAN");
setupParam(driveKnob, driveAttach, "drive", "DRIVE");
setupParam(masterKnob, masterAttach, "masterLevel", "LEVEL");
setupParam(pbRangeKnob, pbRangeAttach, "pitchBendRange", "PB RANGE");
// FX
setupCB(distTypeBox, distTypeAttach, "distType", {"Soft Clip", "Hard Clip", "Foldback", "Overdrive"});
setupParam(distAmountKnob, distAmountAttach, "distAmount", "AMOUNT");
setupParam(distMixKnob, distMixAttach, "distMix", "MIX");
setupParam(compThresholdKnob, compThresholdAttach, "compThreshold", "THRESH");
setupParam(compRatioKnob, compRatioAttach, "compRatio", "RATIO");
setupParam(compAttackKnob, compAttackAttach, "compAttack", "ATTACK");
setupParam(compReleaseKnob, compReleaseAttach, "compRelease", "RELEASE");
setupParam(compMakeupKnob, compMakeupAttach, "compMakeup", "MAKEUP");
setupParam(compMixKnob, compMixAttach, "compMix", "MIX");
setupParam(autoPanRateKnob, autoPanRateAttach, "autoPanRate", "RATE");
setupParam(autoPanDepthKnob, autoPanDepthAttach, "autoPanDepth", "DEPTH");
// LFO
setupKnob(lfo1RateKnob, "RATE");
setupParam(lfo1DepthKnob, lfo1DepthAttach, "lfo1Depth", "DEPTH");
setupCB(lfo1ShapeBox, lfo1ShapeAttach, "lfo1Shape", {"Sine", "Triangle", "Saw", "Square"});
setupCB(lfo1DestBox, lfo1DestAttach, "lfo1Dest", {"Filter", "OSC1", "OSC2", "Both OSC"});
setupCB(lfo1SyncBox, lfo1SyncAttach, "lfo1Sync", {"Sync Off", "Sync On"});
lfo1SyncBox.onChange = [this]() {
setupSyncKnob(lfo1RateKnob, lfo1RateAttach, lfo1BeatAttach, "lfo1Rate", "lfo1Beat", lfo1SyncBox);
};
setupSyncKnob(lfo1RateKnob, lfo1RateAttach, lfo1BeatAttach, "lfo1Rate", "lfo1Beat", lfo1SyncBox);
setupKnob(lfo2RateKnob, "RATE");
setupParam(lfo2DepthKnob, lfo2DepthAttach, "lfo2Depth", "DEPTH");
setupCB(lfo2ShapeBox, lfo2ShapeAttach, "lfo2Shape", {"Sine", "Triangle", "Saw", "Square"});
setupCB(lfo2DestBox, lfo2DestAttach, "lfo2Dest", {"Filter", "OSC1", "OSC2", "Both OSC"});
setupCB(lfo2SyncBox, lfo2SyncAttach, "lfo2Sync", {"Sync Off", "Sync On"});
lfo2SyncBox.onChange = [this]() {
setupSyncKnob(lfo2RateKnob, lfo2RateAttach, lfo2BeatAttach, "lfo2Rate", "lfo2Beat", lfo2SyncBox);
};
setupSyncKnob(lfo2RateKnob, lfo2RateAttach, lfo2BeatAttach, "lfo2Rate", "lfo2Beat", lfo2SyncBox);
// FX2
setupKnob(delayTimeKnob, "TIME");
setupCB(delaySyncBox, delaySyncAttach, "delaySync", {"Sync Off", "Sync On"});
delaySyncBox.onChange = [this]() {
setupSyncKnob(delayTimeKnob, delayTimeAttach, delayBeatAttach, "delayTime", "delayBeat", delaySyncBox);
};
setupSyncKnob(delayTimeKnob, delayTimeAttach, delayBeatAttach, "delayTime", "delayBeat", delaySyncBox);
setupParam(delayFbKnob, delayFbAttach, "delayFeedback", "FBACK");
setupParam(delayMixKnob, delayMixAttach, "delayMix", "MIX");
setupParam(reverbSizeKnob, reverbSizeAttach, "reverbSize", "SIZE");
setupParam(reverbDampKnob, reverbDampAttach, "reverbDamping", "DAMP");
setupParam(reverbMixKnob, reverbMixAttach, "reverbMix", "MIX");
// Preset section: title button + dot-matrix LCD + prev/next
presetButton.setColour(juce::TextButton::buttonColourId, juce::Colour(0xcc2a2a2a));
presetButton.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffccaa44));
presetButton.setLookAndFeel(&comboLaf);
presetButton.onClick = [this]() { showPresetMenu(); };
addAndMakeVisible(presetButton);
addAndMakeVisible(patchLCD);
auto setupArrow = [&](juce::TextButton& btn) {
btn.setColour(juce::TextButton::buttonColourId, juce::Colour(0xcc2a2a2a));
btn.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffccaa44));
btn.setLookAndFeel(&comboLaf);
addAndMakeVisible(btn);
};
setupArrow(prevPresetButton);
setupArrow(nextPresetButton);
prevPresetButton.onClick = [this]() {
auto& presets = processorRef.presetManager.getPresets();
if (presets.empty()) return;
currentPresetIndex = (currentPresetIndex - 1 + static_cast<int>(presets.size())) % static_cast<int>(presets.size());
applyCurrentPreset();
};
nextPresetButton.onClick = [this]() {
auto& presets = processorRef.presetManager.getPresets();
if (presets.empty()) return;
currentPresetIndex = (currentPresetIndex + 1) % static_cast<int>(presets.size());
applyCurrentPreset();
};
randomizeButton.setColour(juce::TextButton::buttonColourId, juce::Colour(0xcc2a2a2a));
randomizeButton.setColour(juce::TextButton::textColourOffId, juce::Colour(0xffccaa44));
randomizeButton.setLookAndFeel(&comboLaf);
randomizeButton.onClick = [this]() { randomizeParameters(); };
addAndMakeVisible(randomizeButton);
auto& presets = processorRef.presetManager.getPresets();
if (!presets.empty()) {
currentPresetIndex = 0;
patchLCD.setText(presets[currentPresetIndex].name);
}
scaleLabel.setText("SCALE", juce::dontSendNotification);
scaleLabel.setFont(juce::Font(9.0f));
scaleLabel.setColour(juce::Label::textColourId, juce::Colour(0xff888888));
scaleLabel.setJustificationType(juce::Justification::centredRight);
addAndMakeVisible(scaleLabel);
auto setupTransposeLabel = [&](juce::Label& label, const juce::String& text) {
label.setText(text, juce::dontSendNotification);
label.setFont(juce::Font(9.0f));
label.setColour(juce::Label::textColourId, juce::Colour(0xff888888));
label.setJustificationType(juce::Justification::centredLeft);
addAndMakeVisible(label);
};
setupTransposeLabel(octaveTransposeLabel, "OCT");
setupTransposeLabel(semitoneTransposeLabel, "SEMI");
setupCombo(octaveTransposeBox);
octaveTransposeBox.addItemList(juce::StringArray{"-3", "-2", "-1", "0", "+1", "+2", "+3"}, 1);
octaveTransposeAttach = std::make_unique<ComboBoxAttachment>(
processorRef.apvts, "octaveTranspose", octaveTransposeBox);
setupCombo(semitoneTransposeBox);
semitoneTransposeBox.addItemList(
juce::StringArray{getSemitoneChoices()}, 1);
semitoneTransposeAttach = std::make_unique<ComboBoxAttachment>(
processorRef.apvts, "semitoneTranspose", semitoneTransposeBox);
addAndMakeVisible(vuMeter);
addAndMakeVisible(waveformDisplay);
addAndMakeVisible(spectrumAnalyzer);
addAndMakeVisible(pianoRoll);
setupCombo(uiScaleBox);
uiScaleBox.addItem("100%", 1);
uiScaleBox.addItem("125%", 2);
uiScaleBox.addItem("150%", 3);
uiScaleBox.addItem("200%", 4);
uiScaleBox.setSelectedId(1, juce::dontSendNotification);
uiScaleBox.onChange = [this]() {
static const float scales[] = {1.0f, 1.25f, 1.5f, 2.0f};
int idx = uiScaleBox.getSelectedId() - 1;
if (idx >= 0 && idx < 4 && onScaleChanged)
onScaleChanged(scales[idx]);
};
}
void MainContentComponent::setupLabel(juce::Label& label, const juce::String& text) {
label.setText(text, juce::dontSendNotification);
label.setFont(juce::Font(13.0f).boldened());
label.setColour(juce::Label::textColourId, juce::Colour(0xffccaa44));
label.setJustificationType(juce::Justification::centred);
addAndMakeVisible(label);
}
void MainContentComponent::setupKnob(juce::Slider& knob, const juce::String& name) {
knob.setSliderStyle(juce::Slider::RotaryVerticalDrag);
knob.setRotaryParameters(juce::MathConstants<float>::pi * 1.2f,
juce::MathConstants<float>::pi * 2.8f,
true);
knob.setTextBoxStyle(juce::Slider::TextBoxBelow, false, 55, 16);
knob.setLookAndFeel(&knobLaf);
knob.setName(name);
knob.setColour(juce::Slider::textBoxTextColourId, juce::Colour(0xffcccccc));
knob.setColour(juce::Slider::textBoxBackgroundColourId, juce::Colour(0xff1a1a1a));
knob.setColour(juce::Slider::textBoxOutlineColourId, juce::Colours::transparentBlack);
addAndMakeVisible(knob);
}
void MainContentComponent::setupCombo(juce::ComboBox& combo) {
combo.setEditableText(false);
combo.setLookAndFeel(&comboLaf);
combo.setColour(juce::ComboBox::backgroundColourId, juce::Colour(0xff2a2a2a));
combo.setColour(juce::ComboBox::textColourId, juce::Colour(0xffcccccc));
combo.setColour(juce::ComboBox::outlineColourId, juce::Colour(0xff555555));
combo.setColour(juce::ComboBox::arrowColourId, juce::Colour(0xffccaa44));
combo.setColour(juce::PopupMenu::backgroundColourId, juce::Colour(0xff2a2a2a));
combo.setColour(juce::PopupMenu::textColourId, juce::Colour(0xffcccccc));
combo.setColour(juce::PopupMenu::highlightedBackgroundColourId, juce::Colour(0xffccaa44));
combo.setColour(juce::PopupMenu::highlightedTextColourId, juce::Colour(0xff1a1a1a));
addAndMakeVisible(combo);
}
void MainContentComponent::setupSyncKnob(juce::Slider& knob,
std::unique_ptr<SliderAttachment>& rateAttach,
std::unique_ptr<SliderAttachment>& beatAttach,
const juce::String& rateId,
const juce::String& beatId,
juce::ComboBox& syncBox) {
bool on = syncBox.getSelectedId() == 2;
rateAttach.reset();
beatAttach.reset();
if (on)
beatAttach = std::make_unique<SliderAttachment>(processorRef.apvts, beatId, knob);
else
rateAttach = std::make_unique<SliderAttachment>(processorRef.apvts, rateId, knob);
}
void MainContentComponent::showPresetMenu() {
juce::PopupMenu menu;
std::vector<juce::String> menuOrder;
auto categories = processorRef.presetManager.getCategories();
juce::String currentName;
juce::String currentCategory;
auto& allPresets = processorRef.presetManager.getPresets();
if (currentPresetIndex >= 0 && currentPresetIndex < static_cast<int>(allPresets.size())) {
currentName = allPresets[currentPresetIndex].name;
currentCategory = allPresets[currentPresetIndex].category;
}
for (auto& cat : categories) {
juce::PopupMenu subMenu;
auto presets = processorRef.presetManager.getPresetsInCategory(cat);
for (auto& preset : presets) {
menuOrder.push_back(preset.name);
bool isCurrent = (preset.name == currentName);
subMenu.addItem(static_cast<int>(menuOrder.size()), preset.name, true, isCurrent);
}
// Tick the section that contains the active patch (cleared when randomize -> index -1)
menu.addSubMenu(cat, subMenu, true, juce::Image(), cat == currentCategory);
}
menu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&presetButton),
[this, menuOrder](int result) {
if (result > 0 && result <= static_cast<int>(menuOrder.size())) {
auto name = menuOrder[static_cast<size_t>(result) - 1];
auto& presets = processorRef.presetManager.getPresets();
for (int i = 0; i < static_cast<int>(presets.size()); ++i) {
if (presets[i].name == name) {
currentPresetIndex = i;
break;
}
}
applyCurrentPreset();
}
});
}
void MainContentComponent::applyCurrentPreset() {
auto& presets = processorRef.presetManager.getPresets();
if (presets.empty()) return;
currentPresetIndex = juce::jlimit(0, static_cast<int>(presets.size()) - 1, currentPresetIndex);
processorRef.fadeOutActiveVoices(0.25f);
processorRef.presetManager.applyPreset(presets[currentPresetIndex].name, processorRef.apvts);
patchLCD.setText(presets[currentPresetIndex].name);
// The ComboBoxAttachment updates the sync boxes without firing onChange,
// so re-sync the rate/beat knob attachments to match.
setupSyncKnob(lfo1RateKnob, lfo1RateAttach, lfo1BeatAttach, "lfo1Rate", "lfo1Beat", lfo1SyncBox);
setupSyncKnob(lfo2RateKnob, lfo2RateAttach, lfo2BeatAttach, "lfo2Rate", "lfo2Beat", lfo2SyncBox);
setupSyncKnob(delayTimeKnob, delayTimeAttach, delayBeatAttach, "delayTime", "delayBeat", delaySyncBox);
}
void MainContentComponent::randomizeParameters() {
juce::Random rand;
processorRef.fadeOutActiveVoices(0.25f);
currentPresetIndex = -1;
for (auto* p : processorRef.getParameters()) {
auto* rap = dynamic_cast<juce::RangedAudioParameter*>(p);
if (rap == nullptr)
continue;
const auto& id = rap->paramID;
if (id == "octaveTranspose" || id == "semitoneTranspose")
continue;
float v = rand.nextFloat();
if (id == "masterLevel")
v = 0.4f + 0.6f * v;
rap->setValueNotifyingHost(v);
}
patchLCD.setText("RANDOM");
// Re-sync the rate/beat knob attachments to match the randomized sync states.
setupSyncKnob(lfo1RateKnob, lfo1RateAttach, lfo1BeatAttach, "lfo1Rate", "lfo1Beat", lfo1SyncBox);
setupSyncKnob(lfo2RateKnob, lfo2RateAttach, lfo2BeatAttach, "lfo2Rate", "lfo2Beat", lfo2SyncBox);
setupSyncKnob(delayTimeKnob, delayTimeAttach, delayBeatAttach, "delayTime", "delayBeat", delaySyncBox);
}
void MainContentComponent::paint(juce::Graphics& g) {
g.fillAll(juce::Colour(0xff1a1a1a));
// Draw background image tiled
if (!bgImageLoaded) {
bgImage = juce::ImageFileFormat::loadFrom(
BinaryData::bg_png, BinaryData::bg_pngSize);
bgImageLoaded = true;
}
if (bgImage.isValid()) {
g.setOpacity(0.35f);
g.drawImage(bgImage,
juce::Rectangle<float>(0.0f, 0.0f, static_cast<float>(getWidth()),
static_cast<float>(getHeight())),
juce::RectanglePlacement::stretchToFit);
g.setOpacity(1.0f);
}
// Load logo
if (!logoLoaded) {
auto img = juce::ImageFileFormat::loadFrom(
BinaryData::newlogo_png, BinaryData::newlogo_pngSize);
if (img.isValid()) {
juce::Image::BitmapData bd(img, juce::Image::BitmapData::readOnly);
int minX = bd.width, maxX = 0, minY = bd.height, maxY = 0;
bool found = false;
for (int y = 0; y < bd.height; ++y) {
for (int x = 0; x < bd.width; ++x) {
int a = bd.data[static_cast<size_t>(y) * bd.lineStride + x * bd.pixelStride + 3];
if (a > 10) {
found = true;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
logoImage = found ? img.getClippedImage(juce::Rectangle<int>(minX, minY, maxX - minX + 1, maxY - minY + 1))
: img;
}
logoLoaded = true;
}
if (logoImage.isValid()) {
int logoW = 440;
int logoH = logoImage.getWidth() > 0
? static_cast<int>(logoW * static_cast<double>(logoImage.getHeight()) / logoImage.getWidth())
: logoW;
int logoX = (getWidth() - logoW) / 2;
int logoY = (124 - logoH) / 2;
auto logoArea = juce::Rectangle<int>(logoX, logoY, logoW, logoH);
juce::Graphics::ScopedSaveState saved(g);
g.setOpacity(0.8f);
g.drawImage(logoImage, logoArea.toFloat(), juce::RectanglePlacement::centred);
}
auto drawSection = [&](int x, int y, int w, int h) {
auto rect = juce::Rectangle<float>(static_cast<float>(x), static_cast<float>(y),
static_cast<float>(w), static_cast<float>(h));
// 3D fill gradient: slight light top, dark bottom
juce::ColourGradient fillGrad(juce::Colour(0xBB222222), rect.getCentreX(), rect.getY(),
juce::Colour(0xBB111111), rect.getCentreX(), rect.getBottom(), false);
g.setGradientFill(fillGrad);
g.fillRoundedRectangle(rect, 9.0f);
// Border
g.setColour(juce::Colour(0xff333333));
g.drawRoundedRectangle(rect, 9.0f, 1.0f);
};
drawSection(10, 128, 498, 200);
drawSection(518, 128, 624, 200);
drawSection(1152, 128, 498, 200);
drawSection(75, 338, 498, 160);
drawSection(583, 338, 502, 160);
drawSection(1095, 338, 490, 160);
drawSection(10, 956, 1640, 136);
// FX sub-sections
auto drawSubSection = [&](int x, int y, int w, int h, const juce::String& title, int titlePadY = 2) {
auto rect = juce::Rectangle<float>(static_cast<float>(x), static_cast<float>(y),
static_cast<float>(w), static_cast<float>(h));
juce::ColourGradient fillGrad(juce::Colour(0xBB222222), rect.getCentreX(), rect.getY(),
juce::Colour(0xBB111111), rect.getCentreX(), rect.getBottom(), false);
g.setGradientFill(fillGrad);
g.fillRoundedRectangle(rect, 9.0f);
g.setColour(juce::Colour(0xff333333));
g.drawRoundedRectangle(rect, 9.0f, 1.0f);
g.setColour(juce::Colour(0xffccaa44));
g.setFont(juce::Font(11.0f).boldened());
g.drawText(title, x + 6, y + titlePadY, w - 12, 14, juce::Justification::centred);
};
drawSubSection(172, 508, 395, 140, "DISTORTION", 6);
drawSubSection(577, 508, 660, 140, "COMPRESSOR", 6);
drawSubSection(1247, 508, 240, 140, "AUTO-PAN", 6);
// LFO sub-sections
drawSubSection(10, 658, 540, 150, "LFO 1", 4);
drawSubSection(560, 658, 530, 150, "LFO 2", 4);
// FX2 sub-sections
drawSubSection(1100, 658, 265, 150, "DELAY", 4);
drawSubSection(1375, 658, 275, 150, "REVERB", 4);
}
void MainContentComponent::resized() {
auto sectionY = 132;
auto knobSize = 112;
auto knobSpacing = 10;
auto labelH = 18;
auto comboH = 48;
osc1Label.setBounds(10, sectionY, 498, labelH);
osc1WaveBox.setBounds(20, sectionY + labelH + 4, 200, 36);
int knobY1 = sectionY + labelH + comboH + 8;
osc1OctKnob.setBounds(20, knobY1, knobSize, knobSize);
osc1SemiKnob.setBounds(20 + knobSize + knobSpacing, knobY1, knobSize, knobSize);
osc1FineKnob.setBounds(20 + (knobSize + knobSpacing) * 2, knobY1, knobSize, knobSize);
osc1LevelKnob.setBounds(20 + (knobSize + knobSpacing) * 3, knobY1, knobSize, knobSize);
osc2Label.setBounds(518, sectionY, 624, labelH);
osc2WaveBox.setBounds(528, sectionY + labelH + 4, 200, 36);
int knobY2 = sectionY + labelH + comboH + 8;
osc2OctKnob.setBounds(528, knobY2, knobSize, knobSize);
osc2SemiKnob.setBounds(528 + knobSize + knobSpacing, knobY2, knobSize, knobSize);
osc2FineKnob.setBounds(528 + (knobSize + knobSpacing) * 2, knobY2, knobSize, knobSize);
osc2LevelKnob.setBounds(528 + (knobSize + knobSpacing) * 3, knobY2, knobSize, knobSize);
phaseOffsetKnob.setBounds(528 + (knobSize + knobSpacing) * 4, knobY2, knobSize, knobSize);
filterLabel.setBounds(1152, sectionY, 498, labelH);
filterTypeBox.setBounds(1162, sectionY + labelH + 4, 200, 36);
int knobYF = sectionY + labelH + comboH + 8;
filterCutoffKnob.setBounds(1162, knobYF, knobSize, knobSize);
filterResKnob.setBounds(1162 + knobSize + knobSpacing, knobYF, knobSize, knobSize);
filterEnvAmtKnob.setBounds(1162 + (knobSize + knobSpacing) * 2, knobYF, knobSize, knobSize);
keyTrackKnob.setBounds(1162 + (knobSize + knobSpacing) * 3, knobYF, knobSize, knobSize);
envLabel.setBounds(75, 342, 498, labelH);
int knobYA = 352 + labelH + 6;
envAttackKnob.setBounds(85, knobYA, knobSize, knobSize);
envDecayKnob.setBounds(85 + knobSize + knobSpacing, knobYA, knobSize, knobSize);
envSustainKnob.setBounds(85 + (knobSize + knobSpacing) * 2, knobYA, knobSize, knobSize);
envReleaseKnob.setBounds(85 + (knobSize + knobSpacing) * 3, knobYA, knobSize, knobSize);
fEnvLabel.setBounds(583, 342, 502, labelH);
int knobYFE = 352 + labelH + 6;
fEnvAttackKnob.setBounds(593, knobYFE, knobSize, knobSize);
fEnvDecayKnob.setBounds(593 + knobSize + knobSpacing, knobYFE, knobSize, knobSize);
fEnvSustainKnob.setBounds(593 + (knobSize + knobSpacing) * 2, knobYFE, knobSize, knobSize);
fEnvReleaseKnob.setBounds(593 + (knobSize + knobSpacing) * 3, knobYFE, knobSize, knobSize);
globalLabel.setBounds(1095, 342, 490, labelH);
int knobYM = 352 + labelH + 6;
panKnob.setBounds(1105, knobYM, knobSize, knobSize);
driveKnob.setBounds(1105 + knobSize + knobSpacing, knobYM, knobSize, knobSize);
masterKnob.setBounds(1105 + (knobSize + knobSpacing) * 2, knobYM, knobSize, knobSize);
pbRangeKnob.setBounds(1105 + (knobSize + knobSpacing) * 3, knobYM, knobSize, knobSize);
// --- FX SECTION ---
int fxY = 480;
int fxKnobSize = 100;
int fxLabelH = 16;
int fxKnobY = fxY + 54; // sub-section y(476) + titlePad(6) + titleH(14) + bottomPad(6) + 4
fxLabel.setBounds(10, fxY + 8, 1640, fxLabelH);
// Distortion sub-section (X=172, Y=476, W=395, H=140)
distTypeBox.setBounds(179, fxKnobY + 2, 140, 36);
distAmountKnob.setBounds(327, fxKnobY, fxKnobSize, fxKnobSize);
distMixKnob.setBounds(437, fxKnobY, fxKnobSize, fxKnobSize);
// Compressor sub-section (X=577, Y=476, W=660, H=140)
compThresholdKnob.setBounds(585, fxKnobY, fxKnobSize, fxKnobSize);
compRatioKnob.setBounds(695, fxKnobY, fxKnobSize, fxKnobSize);
compAttackKnob.setBounds(805, fxKnobY, fxKnobSize, fxKnobSize);
compReleaseKnob.setBounds(915, fxKnobY, fxKnobSize, fxKnobSize);
compMakeupKnob.setBounds(1025, fxKnobY, fxKnobSize, fxKnobSize);
compMixKnob.setBounds(1135, fxKnobY, fxKnobSize, fxKnobSize);
// Auto-Pan sub-section (X=1247, Y=476, W=240, H=140)
autoPanRateKnob.setBounds(1255, fxKnobY, fxKnobSize, fxKnobSize);
autoPanDepthKnob.setBounds(1255 + fxKnobSize + 8, fxKnobY, fxKnobSize, fxKnobSize);
// --- LFO SECTION ---
int lfoY = 638;
int lfoKnobSize = 80;
int lfoKnobY = 658 + 4 + 14 + 6; // subSectionY + titlePad + titleH + bottomPad
lfoLabel.setBounds(10, lfoY, 1080, fxLabelH);
fx2Label.setBounds(1100, lfoY, 540, fxLabelH);
// LFO 1 sub-section (X=15, Y=642, W=530, H=110)
lfo1RateKnob.setBounds(22, lfoKnobY, lfoKnobSize, lfoKnobSize);
lfo1DepthKnob.setBounds(132, lfoKnobY, lfoKnobSize, lfoKnobSize);
lfo1ShapeBox.setBounds(242, lfoKnobY + 2, 130, 36);
lfo1DestBox.setBounds(382, lfoKnobY + 2, 155, 36);
lfo1SyncBox.setBounds(22, lfoKnobY + lfoKnobSize + 6, 200, 28);
// LFO 2 sub-section (X=560, Y=642, W=530, H=110)
lfo2RateKnob.setBounds(568, lfoKnobY, lfoKnobSize, lfoKnobSize);
lfo2DepthKnob.setBounds(678, lfoKnobY, lfoKnobSize, lfoKnobSize);
lfo2ShapeBox.setBounds(788, lfoKnobY + 2, 130, 36);
lfo2DestBox.setBounds(928, lfoKnobY + 2, 155, 36);
lfo2SyncBox.setBounds(568, lfoKnobY + lfoKnobSize + 6, 200, 28);
// --- FX2 SECTION ---
int fx2KnobY = lfoKnobY;
// Delay sub-section (X=1100, Y=690, W=265, H=130)
// Row 1: TIME, FBACK, MIX
delayTimeKnob.setBounds(1110, fx2KnobY, lfoKnobSize, lfoKnobSize);
delayFbKnob.setBounds(1198, fx2KnobY, lfoKnobSize, lfoKnobSize);
delayMixKnob.setBounds(1286, fx2KnobY, lfoKnobSize, lfoKnobSize);
delaySyncBox.setBounds(1110, fx2KnobY + lfoKnobSize + 6, 240, 28);
// Reverb sub-section (X=1375, Y=690, W=265, H=130)
reverbSizeKnob.setBounds(1383, fx2KnobY, lfoKnobSize, lfoKnobSize);
reverbDampKnob.setBounds(1468, fx2KnobY, lfoKnobSize, lfoKnobSize);
reverbMixKnob.setBounds(1553, fx2KnobY, lfoKnobSize, lfoKnobSize);
// --- PRESET + SCALE (vertically centered within top section, y: 0..128) ---
int headerH = 128;
int btnH = 28, btnGap = 8;
int presetGroupH = btnH * 2 + btnGap;
int presetStartY = (headerH - presetGroupH) / 2;
presetButton.setBounds(20, presetStartY, 90, btnH);
randomizeButton.setBounds(20, presetStartY + btnH + btnGap, 90, btnH);
int rowH = 28;
int rowY = (headerH - rowH) / 2;
patchLCD.setBounds(118, rowY, 360, rowH);
prevPresetButton.setBounds(486, rowY, 28, rowH);
nextPresetButton.setBounds(518, rowY, 28, rowH);
int comboH2 = 24, labelH2 = 20;
int comboY2 = (headerH - comboH2) / 2;
int labelY2 = (headerH - labelH2) / 2;
octaveTransposeLabel.setBounds(1112, labelY2, 30, labelH2);
octaveTransposeBox.setBounds(1142, comboY2, 64, comboH2);
semitoneTransposeLabel.setBounds(1214, labelY2, 32, labelH2);
semitoneTransposeBox.setBounds(1246, comboY2, 74, comboH2);
scaleLabel.setBounds(1520, labelY2, 42, labelH2);
uiScaleBox.setBounds(1566, comboY2, 80, comboH2);
// Visualizer area
int vizY = 818;
int vizH = 128;
vuMeter.setBounds(10, vizY, 40, vizH);
waveformDisplay.setBounds(60, vizY, 790, vizH);
spectrumAnalyzer.setBounds(860, vizY, 790, vizH);
// Piano roll
pianoRoll.setBounds(10, 956, 1640, 136);
}
// ===== PianoRollComponent =====
int PianoRollComponent::midiNoteForKey(int index) const {
// Map key index to MIDI note, skipping black keys in the index
// White keys: C D E F G A B (indices 0,2,4,5,7,9,11 in chromatic scale)
static const int whiteToChromatic[] = {0, 2, 4, 5, 7, 9, 11};
int octave = index / 7;
int noteInOctave = index % 7;
return firstMidiNote + octave * 12 + whiteToChromatic[noteInOctave];
}
bool PianoRollComponent::isBlackKey(int midiNote) const {
int noteInOctave = midiNote % 12;
return noteInOctave == 1 || noteInOctave == 3 || noteInOctave == 6 ||
noteInOctave == 8 || noteInOctave == 10;
}
int PianoRollComponent::keyAtPosition(juce::Point<int> pos) const {
int pbRight = pitchBendWidth;
int keysX = pbRight + 8;
int keysWidth = getWidth() - keysX - 6;
int numWhiteKeys = (numKeys * 7) / 12 + 1;
int localWhiteKeyWidth = keysWidth / numWhiteKeys;
// Check black keys first (they're on top)
for (int i = 0; i < numKeys; ++i) {
int note = firstMidiNote + i;
if (!isBlackKey(note)) continue;
int chroma = note % 12;
int octave = (note - firstMidiNote) / 12;
static const int blackToLeftWhite[] = { 0,0,1,1,2,3,3,4,4,5,5,6 };
int leftWhite = blackToLeftWhite[chroma];
int whiteIndexBefore = octave * 7 + leftWhite;
float bx = static_cast<float>(keysX + (whiteIndexBefore + 1) * localWhiteKeyWidth) - static_cast<float>(blackKeyWidth) * 0.5f;
auto blackRect = juce::Rectangle<int>(static_cast<int>(bx), 0, blackKeyWidth, blackKeyHeight);
if (blackRect.contains(pos))
return note;
}
// Check white keys
for (int i = 0; i < numKeys; ++i) {
int note = firstMidiNote + i;
if (isBlackKey(note)) continue;
int chroma = note % 12;
int octave = (note - firstMidiNote) / 12;
int whiteIndex = octave * 7;
if (chroma == 2) whiteIndex += 1;
else if (chroma == 4) whiteIndex += 2;
else if (chroma == 5) whiteIndex += 3;
else if (chroma == 7) whiteIndex += 4;
else if (chroma == 9) whiteIndex += 5;
else if (chroma == 11) whiteIndex += 6;
auto whiteRect = juce::Rectangle<int>(keysX + whiteIndex * localWhiteKeyWidth, 0, localWhiteKeyWidth, whiteKeyHeight);
if (whiteRect.contains(pos))
return note;
}
return -1;
}
void PianoRollComponent::triggerNote(int note, bool on) {
if (on)
processor.noteOn(note, 0.8f);
else
processor.noteOff(note);
}
void PianoRollComponent::paint(juce::Graphics& g) {
int pbRight = pitchBendWidth;
int keysX = pbRight + 8;
int keysWidth = getWidth() - keysX - 6;
int numWhiteKeys = (numKeys * 7) / 12 + 1;
whiteKeyWidth = keysWidth / numWhiteKeys;
// Draw pitch bend strip background
g.setColour(juce::Colour(0xff222222));
g.fillRect(10, 0, pbRight - 10, whiteKeyHeight);
// Right edge border to separate from keys
g.setColour(juce::Colour(0xff444444));
g.drawVerticalLine(pbRight - 8, 0.0f, static_cast<float>(whiteKeyHeight));
// Active bend area indicator (shaded region from center to current position)
int pbCenter = whiteKeyHeight / 2;
float pbNorm = static_cast<float>(currentPitchBend - 64) / 64.0f; // -1..+1
int pbY = pbCenter - static_cast<int>(pbNorm * static_cast<float>(pbCenter - 8));
g.setColour(juce::Colour(0x40ccaa44));
if (pbY < pbCenter)
g.fillRect(14, pbY, 18, pbCenter - pbY);
else
g.fillRect(14, pbCenter, 18, pbY - pbCenter);
// Pitch bend center line
g.setColour(juce::Colour(0xffccaa44));
g.drawHorizontalLine(pbCenter, 12.0f, static_cast<float>(pbRight - 14));
// Pitch bend indicator dot
g.setColour(juce::Colour(0xffccaa44));
g.fillEllipse(14, pbY - 5, 18, 10);
// PB labels
g.setColour(juce::Colour(0xff888888));
g.setFont(9.0f);
g.drawText("+", 14, 2, 18, 12, juce::Justification::centred);
g.drawText("-", 14, whiteKeyHeight - 14, 18, 12, juce::Justification::centred);
// Draw white keys first
for (int i = 0; i < numKeys; ++i) {
int note = firstMidiNote + i;
if (isBlackKey(note)) continue;
int chroma = note % 12;
int octave = (note - firstMidiNote) / 12;
int whiteIndex = octave * 7;
if (chroma == 2) whiteIndex += 1;
else if (chroma == 4) whiteIndex += 2;
else if (chroma == 5) whiteIndex += 3;
else if (chroma == 7) whiteIndex += 4;
else if (chroma == 9) whiteIndex += 5;
else if (chroma == 11) whiteIndex += 6;
int kx = keysX + whiteIndex * whiteKeyWidth;
auto rect = juce::Rectangle<int>(kx, 0, whiteKeyWidth - 1, whiteKeyHeight);
bool pressed = processor.isNoteActive(note);
if (pressed) {
g.setColour(juce::Colour(0xffccaa44));
} else {
juce::ColourGradient grad(juce::Colour(0xffe4e4e4), static_cast<float>(kx), 0.0f,
juce::Colour(0xffc8c8c8), static_cast<float>(kx), static_cast<float>(whiteKeyHeight), false);
g.setGradientFill(grad);
}
g.fillRect(rect);
g.setColour(juce::Colour(0xff555555));
g.drawRect(rect, 1);
// Draw note name on C keys
if (chroma == 0) {
g.setColour(juce::Colour(0xff666666));
g.setFont(9.0f);
g.drawText("C" + juce::String(octave + 3), kx + 2, whiteKeyHeight - 14, whiteKeyWidth - 4, 12,
juce::Justification::centred);
}
}
// Draw black keys on top
for (int i = 0; i < numKeys; ++i) {
int note = firstMidiNote + i;
if (!isBlackKey(note)) continue;
int chroma = note % 12;
int octave = (note - firstMidiNote) / 12;
// chroma → white key index to the LEFT of this black key
static const int blackToLeftWhite[] = { 0,0,1,1,2,3,3,4,4,5,5,6 };
int leftWhite = blackToLeftWhite[chroma];
int whiteIndexBefore = octave * 7 + leftWhite;
float bx = static_cast<float>(keysX + (whiteIndexBefore + 1) * whiteKeyWidth) - static_cast<float>(blackKeyWidth) * 0.5f;
auto rect = juce::Rectangle<int>(static_cast<int>(bx), 0, blackKeyWidth, blackKeyHeight);
bool pressed = processor.isNoteActive(note);
if (pressed) {
g.setColour(juce::Colour(0xffccaa44));
} else {
juce::ColourGradient grad(juce::Colour(0xff444444), static_cast<float>(bx), 0.0f,
juce::Colour(0xff222222), static_cast<float>(bx), static_cast<float>(blackKeyHeight), false);
g.setGradientFill(grad);
}
g.fillRect(rect);
g.setColour(juce::Colour(0xff555555));
g.drawRect(rect, 1);
}
}
void PianoRollComponent::mouseDown(const juce::MouseEvent& e) {
auto pos = e.getPosition();
// Pitch bend strip
if (pos.x < pitchBendWidth) {
float pbNorm = 1.0f - static_cast<float>(pos.y) / static_cast<float>(whiteKeyHeight);
pbNorm = std::clamp(pbNorm, -1.0f, 1.0f);
currentPitchBend = 64 + static_cast<int>(pbNorm * 64.0f);
processor.pitchBendValue.store(pbNorm);
return;
}
int note = keyAtPosition(pos);
if (note >= 0) {
if (lastTriggeredNote >= 0 && lastTriggeredNote != note)
triggerNote(lastTriggeredNote, false);
lastTriggeredNote = note;
triggerNote(note, true);
repaint();
}
}
void PianoRollComponent::mouseUp(const juce::MouseEvent& e) {
// Reset pitch bend to center on release
if (e.getPosition().x < pitchBendWidth || lastTriggeredNote < 0) {
if (e.getPosition().x < pitchBendWidth) {
currentPitchBend = 64;
processor.pitchBendValue.store(0.0f);
repaint();
}
if (lastTriggeredNote < 0) return;
}
if (lastTriggeredNote >= 0) {
triggerNote(lastTriggeredNote, false);
lastTriggeredNote = -1;
repaint();
}
}
void PianoRollComponent::mouseDrag(const juce::MouseEvent& e) {
auto pos = e.getPosition();
// Pitch bend drag
if (pos.x < pitchBendWidth) {
float pbNorm = 1.0f - static_cast<float>(pos.y) / static_cast<float>(whiteKeyHeight);
pbNorm = std::clamp(pbNorm, -1.0f, 1.0f);
currentPitchBend = 64 + static_cast<int>(pbNorm * 64.0f);
processor.pitchBendValue.store(pbNorm);
repaint();
return;
}
int note = keyAtPosition(pos);
if (note >= 0 && note != lastTriggeredNote) {
if (lastTriggeredNote >= 0)
triggerNote(lastTriggeredNote, false);
lastTriggeredNote = note;
triggerNote(note, true);
repaint();
}
}
ChromaFlockEditor::ChromaFlockEditor(ChromaFlockProcessor& p)
: AudioProcessorEditor(&p), content(p) {
addAndMakeVisible(content);
content.onScaleChanged = [this](float s) { setUIScale(s); };
setSize(baseWidth, baseHeight);
setResizable(false, false);
}
ChromaFlockEditor::~ChromaFlockEditor() {}
void ChromaFlockEditor::paint(juce::Graphics& g) {
g.fillAll(juce::Colour(0xff1a1a1a));
}
void ChromaFlockEditor::resized() {
content.setBounds(0, 0, baseWidth, baseHeight);
content.setTransform(juce::AffineTransform::scale(currentScale, currentScale));
}
void ChromaFlockEditor::setUIScale(float newScale) {
currentScale = newScale;
setSize(static_cast<int>(baseWidth * newScale), static_cast<int>(baseHeight * newScale));
}