add arpeggiator, rename presets, move spectrum analyzer into waveform

This commit is contained in:
Armin 2026-08-13 00:38:10 +02:00
commit 3331b4cc16
9 changed files with 1126 additions and 395 deletions

View file

@ -197,7 +197,7 @@ void VuMeter::paint(juce::Graphics& g) {
g.drawText("VU", bounds.withBottom(bounds.getY() + 22.0f).translated(0, 6), juce::Justification::centred);
}
// --- Waveform Display ---
// --- Waveform Display (with semi-transparent spectrum overlay) ---
void WaveformDisplay::paint(juce::Graphics& g) {
auto bounds = getLocalBounds().toFloat().reduced(0.5f);
@ -209,6 +209,116 @@ void WaveformDisplay::paint(juce::Graphics& g) {
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);
@ -245,12 +355,12 @@ void WaveformDisplay::paint(juce::Graphics& g) {
clipPath.addRoundedRectangle(bounds, 9.0f);
g.reduceClipRegion(clipPath);
juce::ColourGradient waveGrad(juce::Colour(0xffc59c07).withAlpha(0.5f), 0.0f, bounds.getY(),
juce::Colour(0xff3d2e02).withAlpha(0.4f), 0.0f, bounds.getBottom(), false);
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(0xffc59c07).withAlpha(0.9f));
g.setColour(juce::Colour(0xff8fa35a).withAlpha(0.9f));
g.strokePath(wavePath, juce::PathStrokeType(1.5f));
}
@ -259,102 +369,6 @@ void WaveformDisplay::paint(juce::Graphics& g) {
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.7f);
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 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);
float lowCut = 80.0f;
float lowPass = 250.0f;
juce::Graphics::ScopedSaveState savedClip(g);
juce::Path clipPath;
clipPath.addRoundedRectangle(bounds, 9.0f);
g.reduceClipRegion(clipPath);
std::vector<float> mags(numPoints + 1);
for (int i = 0; i <= numPoints; ++i) {
float t = static_cast<float>(i) / static_cast<float>(numPoints);
int binStart = static_cast<int>(std::pow(t, 2.0f) * static_cast<float>(maxBin));
int binEnd = static_cast<int>(std::pow(t + 1.0f / static_cast<float>(numPoints), 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;
float centerHz = static_cast<float>((binStart + binEnd) / 2) * binHz;
float rolloff = juce::jlimit(0.0f, 1.0f, (centerHz - lowCut) / (lowPass - lowCut));
mag *= rolloff;
mags[i] = juce::jlimit(0.0f, 1.0f, mag);
}
juce::Path wavePath;
wavePath.startNewSubPath(bounds.getX(), bottom);
juce::Path strokePath;
strokePath.startNewSubPath(bounds.getX(), bottom - mags[0] * h);
for (int i = 0; i <= numPoints; ++i) {
float t = static_cast<float>(i) / static_cast<float>(numPoints);
float x = bounds.getX() + t * w;
float y = bottom - mags[i] * h;
wavePath.lineTo(x, y);
if (i > 0) strokePath.lineTo(x, y);
}
wavePath.lineTo(bounds.getRight(), bottom);
wavePath.closeSubPath();
juce::ColourGradient fillGrad(juce::Colour(0xffc59c07).withAlpha(0.35f), 0.0f, bottom,
juce::Colour(0xffc59c07).withAlpha(0.02f), 0.0f, bottom - h, false);
g.setGradientFill(fillGrad);
g.fillPath(wavePath);
g.setColour(juce::Colour(0xffc59c07).withAlpha(0.9f));
g.strokePath(strokePath, juce::PathStrokeType(1.5f));
g.setColour(juce::Colour(0xffccaa44));
g.setFont(juce::Font(juce::FontOptions(13.0f).withStyle("Bold")));
g.drawText("SPECTRUM", 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]; };
@ -476,7 +490,7 @@ void MidiLed::paint(juce::Graphics& g) {
}
MainContentComponent::MainContentComponent(ChromaFlockProcessor& p)
: processorRef(p), vuMeter(p), waveformDisplay(p), spectrumAnalyzer(p), pianoRoll(p), midiLed(p) {
: processorRef(p), vuMeter(p), waveformDisplay(p), pianoRoll(p), midiLed(p) {
auto setupParam = [&](juce::Slider& knob, std::unique_ptr<SliderAttachment>& attach,
const juce::String& paramId, const juce::String& name) {
@ -667,9 +681,29 @@ MainContentComponent::MainContentComponent(ChromaFlockProcessor& p)
addAndMakeVisible(vuMeter);
addAndMakeVisible(waveformDisplay);
addAndMakeVisible(spectrumAnalyzer);
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");
setupArpLabel(arpPatternLabel, "PATTERN");
setupArpLabel(arpOctavesLabel, "OCTAVES");
setupArpLabel(arpDirectionLabel, "DIR");
setupArpLabel(arpRateLabel, "RATE");
setupCB(arpEnabledBox, arpEnabledAttach, "arpEnabled", {"Off", "On"});
setupCB(arpPatternBox, arpPatternAttach, "arpPattern",
{"Up", "Down", "UpDown", "DownUp", "Random", "As Played", "Chord"});
setupCB(arpOctavesBox, arpOctavesAttach, "arpOctaves", {"1", "2", "3"});
setupCB(arpDirectionBox, arpDirectionAttach, "arpDirection", {"Up", "Down"});
setupCB(arpRateBox, arpRateAttach, "arpRate", {"1/16", "1/8", "1/4", "1/2", "1", "2"});
setupCombo(uiScaleBox);
uiScaleBox.addItem("100%", 1);
uiScaleBox.addItem("125%", 2);
@ -1050,6 +1084,9 @@ void MainContentComponent::paint(juce::Graphics& g) {
// FX2 sub-sections
drawSubSection(1015, 586, 375, 150, "DELAY", 4);
drawSubSection(1395, 586, 255, 150, "REVERB", 4);
// Arpeggiator section (replaces the removed spectrum analyzer)
drawSubSection(860, 746, 790, 128, "ARPEGGIATOR", 6);
}
void MainContentComponent::resized() {
@ -1242,7 +1279,39 @@ void MainContentComponent::resized() {
int vizH = 128;
vuMeter.setBounds(10, vizY, 40, vizH);
waveformDisplay.setBounds(60, vizY, 790, vizH);
spectrumAnalyzer.setBounds(860, 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);
arpEnabledBox.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);
arpDirectionBox.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, 884, 1640, 136);