chromaflock/Source/PluginEditor.cpp
Armin 1a8928e8ce Fix preset name not restored on plugin reopen
The plugin saved only parameter values, so on reopening the editor
reset to the default "Zero State" even though another patch was
selected. Store the chosen preset name as a presetName property on the
APVTS ValueTree (set on preset select, randomize, and .cfl load) so it
round-trips with the saved state and the editor restores the correct
name and index. Parameter tweaks made after selecting a preset are
preserved since only the label is restored, not re-applied.
2026-08-13 14:39:56 +02:00

1745 lines
75 KiB
C++

#include "PluginEditor.h"
#include "BuildInfo.h"
#include <BinaryData.h>
namespace
{
// Returns a sensible default directory for preset files. On Linux,
// File::getSpecialLocation (userHomeDirectory) can resolve to "/" when the
// home directory can't be determined, so fall back through $HOME, the user
// documents directory, and finally the current working directory.
juce::File getDefaultPresetDirectory()
{
auto test = [] (const juce::File& f) { return f.isDirectory() && f.createDirectory().wasOk(); };
if (auto* homeEnv = std::getenv ("HOME"))
if (test (juce::File (homeEnv)))
return juce::File (homeEnv);
if (test (juce::File::getSpecialLocation (juce::File::userHomeDirectory)))
return juce::File::getSpecialLocation (juce::File::userHomeDirectory);
if (test (juce::File::getSpecialLocation (juce::File::userDocumentsDirectory)))
return juce::File::getSpecialLocation (juce::File::userDocumentsDirectory);
return juce::File::getCurrentWorkingDirectory();
}
}
KnobLookAndFeel::KnobLookAndFeel() {
knobOverlay = juce::ImageCache::getFromMemory(BinaryData::metalknob_png,
BinaryData::metalknob_pngSize);
}
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);
// Blurred drop shadow
juce::DropShadow knobShadow(juce::Colour(0x55000000), 10, juce::Point<int>(0, 5));
juce::Path shadowPath;
shadowPath.addEllipse(juce::Rectangle<float>(rx, ry, rw, rw));
knobShadow.drawForPath(g, shadowPath);
// Outer bevel (dark bottom-right, light top-left)
auto bevelPath = juce::Path();
bevelPath.addEllipse(rx, ry, rw, rw);
// Metal knob overlay (clipped to circle, behind gradient)
if (knobOverlay.isValid()) {
juce::Graphics::ScopedSaveState state(g);
juce::Path circlePath;
circlePath.addEllipse(rx, ry, rw, rw);
g.reduceClipRegion(circlePath);
g.setOpacity(0.55f);
g.drawImageWithin(knobOverlay, (int)rx, (int)ry, (int)rw, (int)rw, juce::RectanglePlacement::stretchToFit);
g.setOpacity(1.0f);
}
// Knob body gradient — light top-left to dark bottom-right for 3D bulge
juce::ColourGradient bodyGrad(juce::Colour(0xa3191919), rx, ry,
juce::Colour(0xd31f1f1f), 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.02f + 0.30f * (1.0f - sliderPos), 0.55f, 0.98f, 1.0f);
auto glowColour = juce::Colour::fromHSV(0.02f + 0.30f * (1.0f - sliderPos), 0.55f, 0.98f, 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(juce::FontOptions(13.0f).withStyle("Bold")));
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(juce::FontOptions(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);
juce::ColourGradient grad(juce::Colour(0xffc59c07).withAlpha(0.7f), 0.0f, bounds.getBottom(),
juce::Colour(0xffcc2200).withAlpha(0.7f), 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(juce::FontOptions(13.0f).withStyle("Bold")));
g.drawText("VU", bounds.withBottom(bounds.getY() + 22.0f).translated(0, 6), juce::Justification::centred);
}
// --- Waveform Display (with semi-transparent spectrum overlay) ---
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);
// ---- Spectrum overlay (half-transparent, drawn behind the waveform) ----
if (fft != nullptr) {
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 numPoints = 128;
float w = bounds.getWidth();
float h = bounds.getHeight() - 22.0f;
float bottom = bounds.getBottom() - 2.0f;
int maxBin = fftSize / 4;
float sampleRate = static_cast<float>(processor.getSampleRate());
float binHz = sampleRate / static_cast<float>(fftSize);
const float dbFloor = -48.0f;
// Log-frequency mapping so the low end (sub bass) spreads across the
// display instead of bunching up in the leftmost ~10%.
const float fLow = 20.0f;
const float fHigh = static_cast<float>(maxBin) * binHz;
const float logRange = std::log(fHigh / fLow);
if (specSmooth.size() != static_cast<size_t>(numPoints + 1))
specSmooth.assign(numPoints + 1, 0.0f);
std::vector<float> mags(numPoints + 1);
for (int i = 0; i <= numPoints; ++i) {
float t = static_cast<float>(i) / static_cast<float>(numPoints);
float tN = juce::jmin(t + 1.0f / static_cast<float>(numPoints), 1.0f);
float fL = fLow * std::exp(logRange * t);
float fN = fLow * std::exp(logRange * tN);
int binStart = juce::jmax(1, static_cast<int>(fL / binHz));
int binEnd = static_cast<int>(fN / binHz) + 1;
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;
// dB scale: 0 dB reference ≈ full-scale sine peak, floor at dbFloor.
float lin = mag / static_cast<float>(fftSize) * 4.0f;
float db = 20.0f * std::log10(lin + 1.0e-6f);
mags[i] = juce::jlimit(0.0f, 1.0f, (db - dbFloor) / -dbFloor);
}
// Spatial smoothing between adjacent points (rolling-hill look).
std::vector<float> blurred = mags;
for (int pass = 0; pass < 2; ++pass) {
for (int i = 0; i <= numPoints; ++i) {
float a = mags[static_cast<size_t>(juce::jmax(0, i - 1))];
float c = mags[static_cast<size_t>(juce::jmin(numPoints, i + 1))];
blurred[static_cast<size_t>(i)] = (a + 2.0f * mags[static_cast<size_t>(i)] + c) * 0.25f;
}
mags = blurred;
}
// Time smoothing (EMA) so the curve glides instead of jumping.
// Asymmetric: fast attack, slower fall — a released note's spectrum
// decays away instead of being held up.
const float emaUp = 0.7f;
const float emaDown = 0.6f;
for (int i = 0; i <= numPoints; ++i) {
float& s = specSmooth[static_cast<size_t>(i)];
float m = mags[static_cast<size_t>(i)];
float coeff = m > s ? emaUp : emaDown;
s = coeff * s + (1.0f - coeff) * m;
}
juce::Path specPath;
specPath.startNewSubPath(bounds.getX(), bottom);
for (int i = 0; i <= numPoints; ++i) {
float x = bounds.getX() + (static_cast<float>(i) / static_cast<float>(numPoints)) * w;
float y = bottom - specSmooth[static_cast<size_t>(i)] * h;
specPath.lineTo(x, y);
}
specPath.lineTo(bounds.getRight(), bottom);
specPath.closeSubPath();
{
juce::Graphics::ScopedSaveState saved(g);
juce::Path clipPath;
clipPath.addRoundedRectangle(bounds, 9.0f);
g.reduceClipRegion(clipPath);
juce::ColourGradient specGrad(juce::Colour(0xff7b94b5).withAlpha(0.5f), 0.0f, bottom,
juce::Colour(0xff2a3a4a).withAlpha(0.4f), 0.0f, bottom - h, false);
g.setGradientFill(specGrad);
g.fillPath(specPath);
g.setColour(juce::Colour(0xff9db8d8).withAlpha(0.55f));
g.strokePath(specPath, juce::PathStrokeType(1.2f));
}
}
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);
juce::ColourGradient waveGrad(juce::Colour(0xff8fa35a).withAlpha(0.5f), 0.0f, bounds.getY(),
juce::Colour(0xff2e3a18).withAlpha(0.4f), 0.0f, bounds.getBottom(), false);
g.setGradientFill(waveGrad);
g.fillPath(filledPath);
g.setColour(juce::Colour(0xff8fa35a).withAlpha(0.9f));
g.strokePath(wavePath, juce::PathStrokeType(1.5f));
}
g.setColour(juce::Colour(0xffccaa44));
g.setFont(juce::Font(juce::FontOptions(13.0f).withStyle("Bold")));
g.drawText("WAVE", bounds.withBottom(bounds.getY() + 22.0f).translated(0, 6), juce::Justification::centred);
}
// --- Patch LCD (amber 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(0xcc1a0e00));
g.fillRoundedRectangle(b.toFloat(), 3.0f);
g.setColour(juce::Colour(0xff5a3a00));
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(0xff1a1000));
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(0xff3a2000));
g.fillEllipse(px - pitch * 0.5f, py - pitch * 0.5f, pitch, pitch);
g.setColour(juce::Colour(0xffc59c07));
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;
}
}
// --- MIDI Signal LED ---
void MidiLed::paint(juce::Graphics& g) {
auto b = getLocalBounds().toFloat();
auto centre = b.getCentre();
float radius = juce::jmin(b.getWidth(), b.getHeight()) * 0.5f;
bool active = processor.isAnyNoteActive();
if (active) {
g.setColour(juce::Colour(0xffc59c07).withAlpha(0.2f));
g.fillEllipse(centre.x - radius * 1.8f, centre.y - radius * 1.8f, radius * 3.6f, radius * 3.6f);
}
g.setColour(juce::Colour(0xff1a1200));
g.fillEllipse(centre.x - radius, centre.y - radius, radius * 2.0f, radius * 2.0f);
if (active)
g.setColour(juce::Colour(0xffc59c07));
else
g.setColour(juce::Colour(0xff3a2a06));
g.fillEllipse(centre.x - radius * 0.75f, centre.y - radius * 0.75f, radius * 1.5f, radius * 1.5f);
}
// --- Clickable state LED (on/off, direction) ---
LedToggle::LedToggle(juce::AudioProcessorValueTreeState& apvts, const juce::String& paramId,
const juce::String& onText, const juce::String& offText)
: apvts(apvts), paramId(paramId), onText(onText), offText(offText) {
startTimerHz(30);
setMouseCursor(juce::MouseCursor::PointingHandCursor);
}
void LedToggle::timerCallback() {
bool v = apvts.getRawParameterValue(paramId)->load() > 0.5f;
if (v != lit) {
lit = v;
repaint();
if (onChange)
onChange();
}
}
void LedToggle::mouseDown(const juce::MouseEvent&) {
auto* param = apvts.getParameter(paramId);
float newVal = param->getValue() > 0.5f ? 0.0f : 1.0f;
param->beginChangeGesture();
param->setValueNotifyingHost(newVal);
param->endChangeGesture();
}
void LedToggle::mouseEnter(const juce::MouseEvent&) {
setMouseCursor(juce::MouseCursor::PointingHandCursor);
}
void LedToggle::paint(juce::Graphics& g) {
auto b = getLocalBounds().toFloat();
float ledD = juce::jmin(22.0f, b.getHeight() - 8.0f);
float radius = ledD * 0.5f;
auto centre = juce::Point<float>(b.getX() + radius + 4.0f, b.getCentreY());
if (lit) {
float auraR = radius * 1.9f;
float maxAura = juce::jmin(centre.x - b.getX(), b.getRight() - centre.x,
centre.y - b.getY(), b.getBottom() - centre.y) - 1.0f;
auraR = juce::jmin(auraR, maxAura);
g.setColour(juce::Colour(0xffccaa44).withAlpha(0.2f));
g.fillEllipse(centre.x - auraR, centre.y - auraR, auraR * 2.0f, auraR * 2.0f);
}
g.setColour(juce::Colour(0xff1a1200));
g.fillEllipse(centre.x - radius, centre.y - radius, radius * 2.0f, radius * 2.0f);
g.setColour(juce::Colour(0xff555555));
g.drawEllipse(centre.x - radius, centre.y - radius, radius * 2.0f, radius * 2.0f, 1.5f);
g.setColour(lit ? juce::Colour(0xffeebb55) : juce::Colour(0xff3a2a06));
g.fillEllipse(centre.x - radius * 0.78f, centre.y - radius * 0.78f, radius * 1.56f, radius * 1.56f);
g.setFont(juce::Font(juce::FontOptions(10.0f).withStyle("Bold")));
g.setColour(lit ? juce::Colour(0xffeebb55) : juce::Colour(0xff666666));
g.drawText(lit ? onText : offText,
juce::Rectangle<float>(b.getX() + ledD + 12.0f, b.getY(),
b.getWidth() - ledD - 12.0f, b.getHeight()),
juce::Justification::centredLeft);
}
MainContentComponent::MainContentComponent(ChromaFlockProcessor& p)
: processorRef(p), vuMeter(p), waveformDisplay(p), pianoRoll(p), midiLed(p),
arpEnabledLed(p.apvts, "arpEnabled", "ON", "OFF"),
arpDirectionLed(p.apvts, "arpDirection", "ON", "OFF"),
delayPingPongLed(p.apvts, "delayPingPong", "PINGPONG", "OFF"),
delayInvertLed(p.apvts, "delayInvert", "INVERT", "OFF"),
delayFlattenLed(p.apvts, "delayFlatten", "FLATTEN", "OFF"),
reverbOnLed(p.apvts, "reverbOn", "ON", "OFF"),
lfo1SyncLed(p.apvts, "lfo1Sync", "SYNC ON", "SYNC OFF"),
lfo2SyncLed(p.apvts, "lfo2Sync", "SYNC ON", "SYNC OFF"),
delaySyncLed(p.apvts, "delaySync", "SYNC ON", "SYNC OFF") {
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",
"Pulse 25%", "Pulse 12%", "Rev Saw", "Full Rect", "Half Rect",
"Stair 4", "Stair 8", "Soft Sine", "Sinc", "Exp Pulse", "Double Sine", "SuperSaw"});
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",
"Pulse 25%", "Pulse 12%", "Rev Saw", "Full Rect", "Half Rect",
"Stair 4", "Stair 8", "Soft Sine", "Sinc", "Exp Pulse", "Double Sine", "SuperSaw"});
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",
"Band Pass 24", "High Pass 24", "Notch 24", "LP 48dB"});
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(distSymmetryKnob, distSymmetryAttach, "distSymmetry", "SYMMETRY");
setupParam(distToneKnob, distToneAttach, "distTone", "TONE");
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(autoPanRateKnob, autoPanRateAttach, "autoPanRate", "RATE");
setupParam(autoPanDepthKnob, autoPanDepthAttach, "autoPanDepth", "DEPTH");
setupParam(autoPanPhaseKnob, autoPanPhaseAttach, "autoPanPhase", "PHASE");
setupParam(limiterThresholdKnob, limiterThresholdAttach, "limiterThreshold", "THRESH");
setupParam(limiterCeilingKnob, limiterCeilingAttach, "limiterCeiling", "CEILING");
setupParam(limiterReleaseKnob, limiterReleaseAttach, "limiterRelease", "RELEASE");
// 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", "OSC2 Phase"});
addAndMakeVisible(lfo1SyncLed);
lfo1SyncLed.onChange = [this]() {
setupSyncKnob(lfo1RateKnob, lfo1RateAttach, lfo1BeatAttach, "lfo1Rate", "lfo1Beat", lfo1SyncLed.isLit());
};
setupSyncKnob(lfo1RateKnob, lfo1RateAttach, lfo1BeatAttach, "lfo1Rate", "lfo1Beat", lfo1SyncLed.isLit());
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", "OSC2 Phase"});
addAndMakeVisible(lfo2SyncLed);
lfo2SyncLed.onChange = [this]() {
setupSyncKnob(lfo2RateKnob, lfo2RateAttach, lfo2BeatAttach, "lfo2Rate", "lfo2Beat", lfo2SyncLed.isLit());
};
setupSyncKnob(lfo2RateKnob, lfo2RateAttach, lfo2BeatAttach, "lfo2Rate", "lfo2Beat", lfo2SyncLed.isLit());
// FX2
setupKnob(delayTimeKnob, "TIME");
addAndMakeVisible(delaySyncLed);
delaySyncLed.onChange = [this]() {
setupSyncKnob(delayTimeKnob, delayTimeAttach, delayBeatAttach, "delayTime", "delayBeat", delaySyncLed.isLit());
};
setupSyncKnob(delayTimeKnob, delayTimeAttach, delayBeatAttach, "delayTime", "delayBeat", delaySyncLed.isLit());
setupParam(delayFbKnob, delayFbAttach, "delayFeedback", "FBACK");
setupParam(delayMixKnob, delayMixAttach, "delayMix", "MIX");
addAndMakeVisible(delayPingPongLed);
addAndMakeVisible(delayInvertLed);
addAndMakeVisible(delayFlattenLed);
addAndMakeVisible(reverbOnLed);
setupParam(reverbSizeKnob, reverbSizeAttach, "reverbSize", "SIZE");
setupParam(reverbDampKnob, reverbDampAttach, "reverbDamping", "DAMP");
setupParam(reverbMixKnob, reverbMixAttach, "reverbMix", "MIX");
// Bottom status bar: git build info + actual compilation time
addAndMakeVisible(statusBar);
statusBar.setColour(juce::Label::textColourId, juce::Colour(0xff888888));
statusBar.setFont(juce::Font(juce::FontOptions(11.0f)));
statusBar.setText(juce::String(getBuildInfoString()), juce::dontSendNotification);
// 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);
addAndMakeVisible(midiLed);
patchLCD.onClick = [this]() { showFileMenu(); };
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()) {
// Restore the preset name from the saved state so re-opening the
// plugin shows the patch the user last selected instead of defaulting
// to "Zero State". The parameter values themselves are already
// restored by the host via setStateInformation.
currentPresetIndex = -1;
auto savedName = processorRef.apvts.state.getProperty("presetName", juce::String()).toString();
if (savedName.isNotEmpty()) {
for (size_t i = 0; i < presets.size(); ++i) {
if (presets[i].name == savedName) {
currentPresetIndex = static_cast<int>(i);
break;
}
}
patchLCD.setText(savedName);
} else {
currentPresetIndex = 0;
patchLCD.setText(presets[currentPresetIndex].name);
}
}
scaleLabel.setText("SCALE", juce::dontSendNotification);
scaleLabel.setFont(juce::Font(juce::FontOptions(13.0f).withStyle("Bold")));
scaleLabel.setColour(juce::Label::textColourId, juce::Colour(0xff888888));
scaleLabel.setJustificationType(juce::Justification::centredRight);
addAndMakeVisible(scaleLabel);
midiLabel.setText("MIDI IN", juce::dontSendNotification);
midiLabel.setFont(juce::Font(juce::FontOptions(13.0f).withStyle("Bold")));
midiLabel.setColour(juce::Label::textColourId, juce::Colour(0xff888888));
midiLabel.setJustificationType(juce::Justification::centredLeft);
addAndMakeVisible(midiLabel);
auto setupTransposeLabel = [&](juce::Label& label, const juce::String& text) {
label.setText(text, juce::dontSendNotification);
label.setFont(juce::Font(juce::FontOptions(13.0f).withStyle("Bold")));
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(pianoRoll);
// Arpeggiator section
auto setupArpLabel = [&](juce::Label& label, const juce::String& text) {
label.setText(text, juce::dontSendNotification);
label.setFont(juce::Font(juce::FontOptions(11.0f).withStyle("Bold")));
label.setColour(juce::Label::textColourId, juce::Colour(0xff888888));
label.setJustificationType(juce::Justification::centred);
addAndMakeVisible(label);
};
setupArpLabel(arpEnabledLabel, "ON/OFF");
setupArpLabel(arpPatternLabel, "PATTERN");
setupArpLabel(arpOctavesLabel, "OCTAVES");
setupArpLabel(arpDirectionLabel, "INVERT");
setupArpLabel(arpRateLabel, "RATE");
addAndMakeVisible(arpEnabledLed);
addAndMakeVisible(arpDirectionLed);
setupCB(arpPatternBox, arpPatternAttach, "arpPattern",
{"Up / Minor", "Down / Minor", "Up & Down / Major", "Down & Up / Major", "Random",
"As Played", "Chord", "Up & Down X", "Down & Up X", "Random Once", "Octave Up",
"Octave Down", "Pinky Up", "Pinky Down", "Up / Major", "Down / Major",
"Up & Down / Minor", "Stab 1", "Stab 2", "Stab 3", "Stab 4", "Stab 5"});
setupCB(arpOctavesBox, arpOctavesAttach, "arpOctaves", {"1", "2", "3"});
setupCB(arpRateBox, arpRateAttach, "arpRate", {"1/32", "1/16", "1/8", "1/4", "1/2", "1", "2"});
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(juce::FontOptions(13.0f).withStyle("Bold")));
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,
bool syncOn) {
rateAttach.reset();
beatAttach.reset();
if (syncOn)
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::showFileMenu() {
juce::PopupMenu menu;
menu.addItem(1, "Load Preset...");
menu.addItem(2, "Save Preset...");
menu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&patchLCD),
[this](int result) {
if (result == 1) loadPresetFile();
else if (result == 2) savePresetFile();
});
}
static const char* kCflTag = "ChromaFlockPatch";
#include "Version.h"
static const juce::String kCflVersion = CHROMAFLOCK_VERSION;
juce::String MainContentComponent::currentPresetName() const {
auto& presets = processorRef.presetManager.getPresets();
if (currentPresetIndex >= 0 && currentPresetIndex < static_cast<int>(presets.size()))
return presets[currentPresetIndex].name;
return patchLCD.getText();
}
void MainContentComponent::savePresetFile() {
auto* chooser = new juce::FileChooser("Save ChromaFlock Preset",
getDefaultPresetDirectory()
.getChildFile("chromaflock-" + currentPresetName().replaceCharacter(' ', '-') + ".cfl"),
"*.cfl");
chooser->launchAsync(juce::FileBrowserComponent::saveMode
| juce::FileBrowserComponent::canSelectFiles
| juce::FileBrowserComponent::warnAboutOverwriting,
[this, chooser](const juce::FileChooser& c) {
std::unique_ptr<juce::FileChooser> deleter(chooser);
auto file = c.getResult();
if (file == juce::File())
return;
// Always force the .cfl extension (macOS native panel does not
// reliably append it) and lowercase the filename.
juce::String name = file.getFileNameWithoutExtension();
if (name.toLowerCase() != name)
file = file.getParentDirectory().getChildFile(name.toLowerCase());
file = file.withFileExtension(".cfl");
auto state = processorRef.apvts.copyState();
std::unique_ptr<juce::XmlElement> presetXml(state.createXml());
std::unique_ptr<juce::XmlElement> root(new juce::XmlElement(kCflTag));
root->setAttribute("version", kCflVersion);
root->setAttribute("generator", "ChromaFlock");
root->addChildElement(presetXml.release());
if (!root->writeTo(file))
juce::NativeMessageBox::showMessageBoxAsync(juce::AlertWindow::WarningIcon,
"Save Failed", "Could not write preset file:\n" + file.getFullPathName(), this);
else
patchLCD.setText(file.getFileNameWithoutExtension());
});
}
void MainContentComponent::loadPresetFile() {
auto* chooser = new juce::FileChooser("Load ChromaFlock Preset",
getDefaultPresetDirectory(),
"*.cfl");
chooser->launchAsync(juce::FileBrowserComponent::openMode
| juce::FileBrowserComponent::canSelectFiles,
[this, chooser](const juce::FileChooser& c) {
std::unique_ptr<juce::FileChooser> deleter(chooser);
auto file = c.getResult();
if (file == juce::File() || !file.existsAsFile())
return;
std::unique_ptr<juce::XmlElement> root(juce::XmlDocument::parse(file));
if (root == nullptr || !root->hasTagName(kCflTag)) {
juce::NativeMessageBox::showMessageBoxAsync(juce::AlertWindow::WarningIcon,
"Invalid File", "This is not a valid ChromaFlock (.cfl) preset file.", this);
return;
}
auto fileVersion = root->getStringAttribute("version", "");
if (fileVersion != kCflVersion) {
juce::NativeMessageBox::showMessageBoxAsync(juce::AlertWindow::WarningIcon,
"Version Mismatch",
"This preset was saved with ChromaFlock " + (fileVersion.isEmpty() ? "<unknown>" : fileVersion) +
", but you are running ChromaFlock " + kCflVersion +
".\nThe preset may not sound as intended.", this);
}
auto* presetXml = root->getChildByName(processorRef.apvts.state.getType());
if (presetXml == nullptr) {
juce::NativeMessageBox::showMessageBoxAsync(juce::AlertWindow::WarningIcon,
"Invalid File", "The preset file does not contain ChromaFlock parameter data.", this);
return;
}
processorRef.fadeOutActiveVoices(0.25f);
processorRef.apvts.replaceState(juce::ValueTree::fromXml(*presetXml));
currentPresetIndex = -1;
patchLCD.setText(file.getFileNameWithoutExtension());
processorRef.apvts.state.setProperty("presetName", file.getFileNameWithoutExtension(), nullptr);
setupSyncKnob(lfo1RateKnob, lfo1RateAttach, lfo1BeatAttach, "lfo1Rate", "lfo1Beat", lfo1SyncLed.isLit());
setupSyncKnob(lfo2RateKnob, lfo2RateAttach, lfo2BeatAttach, "lfo2Rate", "lfo2Beat", lfo2SyncLed.isLit());
setupSyncKnob(delayTimeKnob, delayTimeAttach, delayBeatAttach, "delayTime", "delayBeat", delaySyncLed.isLit());
});
}
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);
processorRef.apvts.state.setProperty("presetName", presets[currentPresetIndex].name, nullptr);
// The LedToggle updates itself via its timer, so re-sync the rate/beat
// knob attachments to match.
setupSyncKnob(lfo1RateKnob, lfo1RateAttach, lfo1BeatAttach, "lfo1Rate", "lfo1Beat", lfo1SyncLed.isLit());
setupSyncKnob(lfo2RateKnob, lfo2RateAttach, lfo2BeatAttach, "lfo2Rate", "lfo2Beat", lfo2SyncLed.isLit());
setupSyncKnob(delayTimeKnob, delayTimeAttach, delayBeatAttach, "delayTime", "delayBeat", delaySyncLed.isLit());
}
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");
processorRef.apvts.state.setProperty("presetName", "RANDOM", nullptr);
// Re-sync the rate/beat knob attachments to match the randomized sync states.
setupSyncKnob(lfo1RateKnob, lfo1RateAttach, lfo1BeatAttach, "lfo1Rate", "lfo1Beat", lfo1SyncLed.isLit());
setupSyncKnob(lfo2RateKnob, lfo2RateAttach, lfo2BeatAttach, "lfo2Rate", "lfo2Beat", lfo2SyncLed.isLit());
setupSyncKnob(delayTimeKnob, delayTimeAttach, delayBeatAttach, "delayTime", "delayBeat", delaySyncLed.isLit());
}
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);
}
// Header section: no boxed background (border removed)
// Load logo
if (!logoLoaded) {
auto img = juce::ImageFileFormat::loadFrom(
BinaryData::logo_png, BinaryData::logo_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 = 352;
int logoH = logoImage.getWidth() > 0
? static_cast<int>(logoW * static_cast<double>(logoImage.getHeight()) / logoImage.getWidth())
: logoW;
int logoX = (getWidth() - logoW) / 2;
int logoY = (80 - logoH) / 2 - 2;
auto logoArea = juce::Rectangle<int>(logoX, logoY, logoW, logoH);
juce::Graphics::ScopedSaveState saved(g);
g.setOpacity(1.0f);
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));
// Soft glow behind section
auto glow = rect.expanded(6.0f);
g.setColour(juce::Colour(0xff222222).withAlpha(0.25f));
g.fillRoundedRectangle(glow, 12.0f);
// 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, 88, 498, 200);
drawSection(518, 88, 624, 200);
drawSection(1152, 88, 498, 200);
drawSection(10, 298, 563, 160);
drawSection(583, 298, 502, 160);
drawSection(1095, 298, 555, 160);
drawSection(10, 892, 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));
auto glow = rect.expanded(6.0f);
g.setColour(juce::Colour(0xff222222).withAlpha(0.25f));
g.fillRoundedRectangle(glow, 12.0f);
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(juce::FontOptions(11.0f).withStyle("Bold")));
g.drawText(title, x + 6, y + titlePadY, w - 12, 14, juce::Justification::centred);
};
drawSubSection(10, 468, 510, 115, "DISTORTION", 6);
drawSubSection(528, 468, 468, 115, "COMPRESSION", 6);
drawSubSection(1004, 468, 268, 115, "PANNING", 6);
drawSubSection(1280, 468, 370, 115, "LIMITER", 6);
// LFO sub-sections
drawSubSection(10, 594, 464, 150, "LFO 1", 4);
drawSubSection(480, 594, 464, 150, "LFO 2", 4);
// FX2 sub-sections
drawSubSection(950, 594, 368, 150, "DELAY", 4);
drawSubSection(1324, 594, 326, 150, "REVERB", 4);
// Arpeggiator section (replaces the removed spectrum analyzer)
drawSubSection(860, 754, 790, 128, "ARPEGGIATOR", 6);
// Status bar divider
g.setColour(juce::Colour(0xff333333));
g.drawHorizontalLine(1034, 10.0f, 1650.0f);
}
void MainContentComponent::resized() {
auto sectionY = 92;
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, 28);
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, 28);
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, 28);
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(10, 302, 563, labelH);
{
int rowW = 4 * knobSize + 3 * knobSpacing;
int sx = 10 + (563 - rowW) / 2;
int knobYA = 312 + labelH + 6;
envAttackKnob.setBounds(sx, knobYA, knobSize, knobSize);
envDecayKnob.setBounds(sx + (knobSize + knobSpacing), knobYA, knobSize, knobSize);
envSustainKnob.setBounds(sx + (knobSize + knobSpacing) * 2, knobYA, knobSize, knobSize);
envReleaseKnob.setBounds(sx + (knobSize + knobSpacing) * 3, knobYA, knobSize, knobSize);
}
fEnvLabel.setBounds(583, 302, 502, labelH);
{
int rowW = 4 * knobSize + 3 * knobSpacing;
int sx = 583 + (502 - rowW) / 2;
int knobYFE = 312 + labelH + 6;
fEnvAttackKnob.setBounds(sx, knobYFE, knobSize, knobSize);
fEnvDecayKnob.setBounds(sx + (knobSize + knobSpacing), knobYFE, knobSize, knobSize);
fEnvSustainKnob.setBounds(sx + (knobSize + knobSpacing) * 2, knobYFE, knobSize, knobSize);
fEnvReleaseKnob.setBounds(sx + (knobSize + knobSpacing) * 3, knobYFE, knobSize, knobSize);
}
globalLabel.setBounds(1095, 302, 555, labelH);
{
int rowW = 4 * knobSize + 3 * knobSpacing;
int sx = 1095 + (555 - rowW) / 2;
int knobYM = 312 + labelH + 6;
panKnob.setBounds(sx, knobYM, knobSize, knobSize);
driveKnob.setBounds(sx + (knobSize + knobSpacing), knobYM, knobSize, knobSize);
masterKnob.setBounds(sx + (knobSize + knobSpacing) * 2, knobYM, knobSize, knobSize);
pbRangeKnob.setBounds(sx + (knobSize + knobSpacing) * 3, knobYM, knobSize, knobSize);
}
// --- FX SECTION ---
int fxY = 440;
int fxKnobSize = 80;
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=10, W=512) — dropdown fixed at x=17, 3 knobs fill remaining width
distTypeBox.setBounds(17, fxKnobY + (fxKnobSize - 28) / 2, 140, 28);
{
int availLeft = 17 + 140;
int availW = (10 + 512) - availLeft;
int rowW = 3 * fxKnobSize + 2 * 6;
int dx = availLeft + (availW - rowW) / 2;
distAmountKnob.setBounds(dx, fxKnobY, fxKnobSize, fxKnobSize);
distSymmetryKnob.setBounds(dx + (fxKnobSize + 6), fxKnobY, fxKnobSize, fxKnobSize);
distToneKnob.setBounds(dx + (fxKnobSize + 6) * 2, fxKnobY, fxKnobSize, fxKnobSize);
}
// Compression sub-section (X=528, W=470)
{
int rowW = 5 * fxKnobSize + 4 * 6;
int sx = 528 + (470 - rowW) / 2;
compThresholdKnob.setBounds(sx, fxKnobY, fxKnobSize, fxKnobSize);
compRatioKnob.setBounds(sx + (fxKnobSize + 6), fxKnobY, fxKnobSize, fxKnobSize);
compAttackKnob.setBounds(sx + (fxKnobSize + 6) * 2, fxKnobY, fxKnobSize, fxKnobSize);
compReleaseKnob.setBounds(sx + (fxKnobSize + 6) * 3, fxKnobY, fxKnobSize, fxKnobSize);
compMakeupKnob.setBounds(sx + (fxKnobSize + 6) * 4, fxKnobY, fxKnobSize, fxKnobSize);
}
// Auto-Pan sub-section (X=1004, W=270)
{
int rowW = 3 * fxKnobSize + 2 * 6;
int ax = 1004 + (270 - rowW) / 2;
autoPanRateKnob.setBounds(ax, fxKnobY, fxKnobSize, fxKnobSize);
autoPanDepthKnob.setBounds(ax + (fxKnobSize + 6), fxKnobY, fxKnobSize, fxKnobSize);
autoPanPhaseKnob.setBounds(ax + (fxKnobSize + 6) * 2, fxKnobY, fxKnobSize, fxKnobSize);
}
// Limiter sub-section (X=1280, W=370)
{
int rowW = 3 * fxKnobSize + 2 * 6;
int lx = 1280 + (370 - rowW) / 2;
limiterThresholdKnob.setBounds(lx, fxKnobY, fxKnobSize, fxKnobSize);
limiterCeilingKnob.setBounds(lx + (fxKnobSize + 6), fxKnobY, fxKnobSize, fxKnobSize);
limiterReleaseKnob.setBounds(lx + (fxKnobSize + 6) * 2, fxKnobY, fxKnobSize, fxKnobSize);
}
// --- LFO SECTION ---
int lfoY = 574;
int lfoKnobSize = 80;
int lfoKnobY = 594 + 4 + 14 + 6; // subSectionY + titlePad + titleH + bottomPad
lfoLabel.setBounds(10, lfoY, 934, fxLabelH);
fx2Label.setBounds(950, lfoY, 700, fxLabelH);
// LFO 1 sub-section (X=10, Y=642, W=464, H=110)
lfo1RateKnob.setBounds(22, lfoKnobY, lfoKnobSize, lfoKnobSize);
lfo1DepthKnob.setBounds(132, lfoKnobY, lfoKnobSize, lfoKnobSize);
lfo1ShapeBox.setBounds(242, lfoKnobY + (lfoKnobSize - 28) / 2, 105, 28);
lfo1DestBox.setBounds(357, lfoKnobY + (lfoKnobSize - 28) / 2, 105, 28);
lfo1SyncLed.setBounds(22, lfoKnobY + lfoKnobSize + 6, 200, 28);
// LFO 2 sub-section (X=480, Y=642, W=464, H=110)
lfo2RateKnob.setBounds(492, lfoKnobY, lfoKnobSize, lfoKnobSize);
lfo2DepthKnob.setBounds(602, lfoKnobY, lfoKnobSize, lfoKnobSize);
lfo2ShapeBox.setBounds(712, lfoKnobY + (lfoKnobSize - 28) / 2, 105, 28);
lfo2DestBox.setBounds(827, lfoKnobY + (lfoKnobSize - 28) / 2, 105, 28);
lfo2SyncLed.setBounds(492, lfoKnobY + lfoKnobSize + 6, 200, 28);
// --- FX2 SECTION ---
int fx2KnobY = lfoKnobY;
// Delay sub-section: TIME, FBACK, MIX knobs + ping-pong toggle column + sync combo.
// The ping-pong column (PINGPONG / INVERT / FLATTEN) is stacked on the right,
// just below/under the ping-pong LED, with ping-pong itself nudged up a bit.
int dx = 962;
int dGap = lfoKnobSize + 8;
delayTimeKnob.setBounds(dx, fx2KnobY, lfoKnobSize, lfoKnobSize);
delayFbKnob.setBounds(dx + dGap, fx2KnobY, lfoKnobSize, lfoKnobSize);
delayMixKnob.setBounds(dx + dGap * 2, fx2KnobY, lfoKnobSize, lfoKnobSize);
int ppX = 1230;
int ppW = 104;
int ppH = 36;
int ppGap = 4;
int ppStep = ppH + ppGap;
delayPingPongLed.setBounds(ppX, fx2KnobY, ppW, ppH);
delayInvertLed.setBounds(ppX, fx2KnobY + ppStep, ppW, ppH);
delayFlattenLed.setBounds(ppX, fx2KnobY + ppStep * 2, ppW, ppH);
delaySyncLed.setBounds(dx, fx2KnobY + lfoKnobSize + 6, 250, 28);
// Reverb sub-section: SIZE, DAMP, MIX (right edge aligned to 1650)
{
int rx = 1336;
reverbSizeKnob.setBounds(rx, fx2KnobY, lfoKnobSize, lfoKnobSize);
reverbDampKnob.setBounds(rx + (lfoKnobSize + 4), fx2KnobY, lfoKnobSize, lfoKnobSize);
reverbMixKnob.setBounds(rx + (lfoKnobSize + 4) * 2, fx2KnobY, lfoKnobSize, lfoKnobSize);
// On/off LED on the right, filling the remaining space before the box edge
reverbOnLed.setBounds(rx + (lfoKnobSize + 4) * 3, fx2KnobY, 1650 - (rx + (lfoKnobSize + 4) * 3), lfoKnobSize);
}
// --- PRESET + SCALE (vertically centered within top section, y: 0..128) ---
int headerH = 80;
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;
int lcdX = 150; // gap between the preset/random buttons and the patch LCD
patchLCD.setBounds(lcdX, rowY, 360, rowH);
prevPresetButton.setBounds(lcdX + 360 + 8, rowY, 28, rowH);
nextPresetButton.setBounds(lcdX + 360 + 8 + 32, rowY, 28, rowH);
int comboH2 = 28, labelH2 = 20;
int comboY2 = (headerH - comboH2) / 2;
int labelY2 = (headerH - labelH2) / 2;
octaveTransposeLabel.setBounds(1112, labelY2, 44, labelH2);
octaveTransposeBox.setBounds(1156, comboY2, 64, comboH2);
semitoneTransposeLabel.setBounds(1226, labelY2, 46, labelH2);
semitoneTransposeBox.setBounds(1272, comboY2, 74, comboH2);
midiLabel.setBounds(1354, labelY2, 64, labelH2);
midiLed.setBounds(1418, (headerH - 10) / 2, 10, 10);
scaleLabel.setBounds(1499, labelY2, 58, labelH2);
uiScaleBox.setBounds(1559, comboY2, 80, comboH2);
// Visualizer area
int vizY = 754;
int vizH = 128;
vuMeter.setBounds(10, vizY, 40, vizH);
waveformDisplay.setBounds(60, vizY, 790, vizH);
// Arpeggiator controls (right of the waveform display)
{
int arpX = 860, arpY = vizY, arpW = 790;
int comboH = 28;
int labelH = 16;
int labelY = arpY + 26;
int comboY = labelY + labelH + 8;
int widths[] = {90, 170, 90, 90, 110};
int gap = 22;
int totalW = widths[0] + widths[1] + widths[2] + widths[3] + widths[4] + gap * 4;
int x = arpX + (arpW - totalW) / 2;
arpEnabledLabel.setBounds(x, labelY, widths[0], labelH);
arpEnabledLed.setBounds(x, comboY, widths[0], comboH);
x += widths[0] + gap;
arpPatternLabel.setBounds(x, labelY, widths[1], labelH);
arpPatternBox.setBounds(x, comboY, widths[1], comboH);
x += widths[1] + gap;
arpOctavesLabel.setBounds(x, labelY, widths[2], labelH);
arpOctavesBox.setBounds(x, comboY, widths[2], comboH);
x += widths[2] + gap;
arpDirectionLabel.setBounds(x, labelY, widths[3], labelH);
arpDirectionLed.setBounds(x, comboY, widths[3], comboH);
x += widths[3] + gap;
arpRateLabel.setBounds(x, labelY, widths[4], labelH);
arpRateBox.setBounds(x, comboY, widths[4], comboH);
}
// Piano roll
pianoRoll.setBounds(10, 892, 1640, 136);
// Status bar (bottom strip)
statusBar.setBounds(10, 1036, 1640, 20);
}
// ===== 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).withAlpha(0.7f));
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).withAlpha(0.7f));
} else {
juce::ColourGradient grad(juce::Colour(0xffe4e4e4).withAlpha(0.7f), static_cast<float>(kx), 0.0f,
juce::Colour(0xffc8c8c8).withAlpha(0.7f), 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(0xff996600));
} 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: begin a drag that starts from center (0 bend)
if (pos.x < pitchBendWidth) {
pitchBendDragging = true;
pitchBendGliding = false;
pitchBendStartY = pos.y;
currentPitchBend = 64;
processor.pitchBendValue.store(0.0f);
repaint();
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 (only if it was actually dragged)
if (pitchBendDragging) {
pitchBendDragging = false;
pitchBendGliding = true;
pitchBendGlideStart = static_cast<float>(currentPitchBend - 64) / 64.0f;
pitchBendGlideStartMs = juce::Time::currentTimeMillis();
repaint();
}
if (lastTriggeredNote >= 0) {
triggerNote(lastTriggeredNote, false);
lastTriggeredNote = -1;
repaint();
}
}
void PianoRollComponent::timerCallback() {
if (pitchBendGliding) {
float t = static_cast<float>(juce::Time::currentTimeMillis() - pitchBendGlideStartMs)
/ static_cast<float>(pitchBendGlideMs);
if (t >= 1.0f) {
pitchBendGliding = false;
currentPitchBend = 64;
processor.pitchBendValue.store(0.0f);
} else {
float pbNorm = pitchBendGlideStart * (1.0f - t);
currentPitchBend = 64 + static_cast<int>(pbNorm * 64.0f);
processor.pitchBendValue.store(pbNorm);
}
repaint();
} else {
repaint();
}
}
void PianoRollComponent::mouseDrag(const juce::MouseEvent& e) {
auto pos = e.getPosition();
// Pitch bend drag — relative to where the drag started (starts at 0)
if (pos.x < pitchBendWidth) {
pitchBendDragging = true;
pitchBendGliding = false;
float pbNorm = 2.0f * static_cast<float>(pitchBendStartY - 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));
}