From 4732240cc70151cc4b27e2df02849010ee7b5e11 Mon Sep 17 00:00:00 2001 From: Armin Date: Thu, 6 Aug 2026 15:51:56 +0200 Subject: [PATCH] Polish GUI, add pattern retrig option, and rework voice DSP --- Source/PluginEditor.cpp | 295 +++++++++++++++++++++++++++------- Source/PluginEditor.h | 24 ++- Source/PluginProcessor.cpp | 122 +++++++++++--- Source/PluginProcessor.h | 11 ++ Source/dsp/SynthVoice.h | 151 +++++++++++++++-- Source/dsp/WaveTables.h | 5 - Source/ui/SequencerMatrix.cpp | 65 +++++--- 7 files changed, 545 insertions(+), 128 deletions(-) diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 7d08f42..53a628b 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -8,6 +8,10 @@ using namespace monostep; namespace { +constexpr int oscDropW = 96; // OSC 1 / OSC 2 dropdown width +constexpr int oscLabelW = 46; // label strip to the left of each dropdown +constexpr int oscGapW = 8; // gap between the dropdown column and the knobs + juce::ColourGradient orangeGradient (const juce::Rectangle& area) { const auto top = area.getTopLeft().translated (area.getWidth() * 0.5f, 0.0f); @@ -40,11 +44,16 @@ void EditorLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int w const float end = rotaryEndAngle; const float angle = start + sliderPos * (end - start); - g.setColour (colours::grid); - g.fillEllipse (centre.getX() - radius, centre.getY() - radius, radius * 2.0f, radius * 2.0f); + const auto bodyRect = juce::Rectangle (centre.getX() - radius, centre.getY() - radius, + radius * 2.0f, radius * 2.0f); - g.setColour (colours::gridLight); - g.drawEllipse (centre.getX() - radius, centre.getY() - radius, radius * 2.0f, radius * 2.0f, 1.0f); + // Raised body: lighter at the top edge, darker at the bottom. + juce::ColourGradient bodyGrad (colours::gridLight.brighter (0.15f), + centre.getX(), bodyRect.getY(), + colours::grid.darker (0.18f), + centre.getX(), bodyRect.getBottom(), false); + g.setGradientFill (bodyGrad); + g.fillEllipse (bodyRect); // Almost-vertical (-2°) shading overlay to give the dial a subtle 3D look. constexpr float degToRad = juce::MathConstants::pi / 180.0f; @@ -54,10 +63,14 @@ void EditorLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int w const auto topPoint = centre + juce::Point ( radius * tiltSin, -radius * tiltCos); const auto bottomPoint = centre + juce::Point (-radius * tiltSin, radius * tiltCos); - juce::ColourGradient shading (juce::Colours::white.withAlpha (0.07f), topPoint, - juce::Colours::black.withAlpha (0.20f), bottomPoint, false); + juce::ColourGradient shading (juce::Colours::black.withAlpha (0.10f), topPoint, + juce::Colours::black.withAlpha (0.10f), bottomPoint, false); g.setGradientFill (shading); - g.fillEllipse (centre.getX() - radius, centre.getY() - radius, radius * 2.0f, radius * 2.0f); + g.fillEllipse (bodyRect); + + // Outer rim. + g.setColour (colours::gridLight); + g.drawEllipse (bodyRect, 1.0f); if (end - start < 6.0f) { @@ -88,11 +101,27 @@ void EditorLookAndFeel::drawToggleButton (juce::Graphics& g, juce::ToggleButton& const auto bounds = button.getLocalBounds().toFloat(); if (button.getToggleState()) + { g.setGradientFill (orangeGradient (bounds)); + g.fillRoundedRectangle (bounds, 4.0f); + } else - g.setColour (colours::grid); + { + // Raised, slightly-3D inactive button. + juce::ColourGradient offGrad (colours::gridLight.brighter (0.12f), + bounds.getX(), bounds.getY(), + colours::grid.darker (0.15f), + bounds.getX(), bounds.getBottom(), false); + g.setGradientFill (offGrad); + g.fillRoundedRectangle (bounds, 4.0f); - g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (colours::gridLight.brighter (0.35f).withAlpha (0.45f)); + g.drawHorizontalLine (juce::roundToInt (bounds.getY()) + 1, + bounds.getX() + 3.0f, bounds.getRight() - 3.0f); + + g.setColour (colours::bg.withAlpha (0.30f)); + g.drawRoundedRectangle (bounds.reduced (0.5f), 4.0f, 1.0f); + } g.setFont (font (11.0f)); g.setColour (button.getToggleState() ? colours::bg : colours::text); @@ -105,11 +134,25 @@ void EditorLookAndFeel::drawButtonBackground (juce::Graphics& g, juce::Button& b const auto bounds = button.getLocalBounds().toFloat().reduced (0.5f); if (button.getToggleState()) + { g.setGradientFill (orangeGradient (bounds)); + g.fillRoundedRectangle (bounds, 4.0f); + } else - g.setColour (highlighted ? colours::gridLight : colours::grid); + { + // Raised, slightly-3D inactive button. + const juce::Colour topColour = highlighted ? colours::gridLight.brighter (0.22f) + : colours::gridLight.brighter (0.10f); + juce::ColourGradient offGrad (topColour, bounds.getX(), bounds.getY(), + colours::grid.darker (0.15f), + bounds.getX(), bounds.getBottom(), false); + g.setGradientFill (offGrad); + g.fillRoundedRectangle (bounds, 4.0f); - g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (colours::gridLight.brighter (0.35f).withAlpha (0.45f)); + g.drawHorizontalLine (juce::roundToInt (bounds.getY()) + 1, + bounds.getX() + 3.0f, bounds.getRight() - 3.0f); + } } void EditorLookAndFeel::drawButtonText (juce::Graphics& g, juce::TextButton& button, bool, bool) @@ -124,11 +167,25 @@ void EditorLookAndFeel::drawComboBox (juce::Graphics& g, int width, int height, { const auto bounds = juce::Rectangle (1.0f, 1.0f, (float) (width - 2), (float) (height - 2)); - g.setColour (colours::grid); + // Subtle drop shadow under the box. + g.setColour (juce::Colours::black.withAlpha (0.30f)); + g.fillRoundedRectangle (bounds.translated (0.0f, 1.5f), 4.0f); + + // Raised box: lighter top edge, darker bottom. + juce::ColourGradient boxGrad (colours::gridLight.brighter (0.10f), + bounds.getX(), bounds.getY(), + colours::grid.darker (0.15f), + bounds.getX(), bounds.getBottom(), false); + g.setGradientFill (boxGrad); g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (colours::gridLight); g.drawRoundedRectangle (bounds, 4.0f, 1.0f); + g.setColour (colours::gridLight.brighter (0.35f).withAlpha (0.45f)); + g.drawHorizontalLine (juce::roundToInt (bounds.getY()) + 1, + bounds.getX() + 4.0f, bounds.getRight() - 4.0f); + const float cx = (float) (width - 15); const float cy = (float) height * 0.5f; @@ -145,7 +202,10 @@ juce::Font EditorLookAndFeel::getComboBoxFont (juce::ComboBox&) void EditorLookAndFeel::drawPopupMenuBackground (juce::Graphics& g, int width, int height) { - g.setColour (colours::panel); + // Slightly recessed panel with a gentle top-to-bottom gradient. + juce::ColourGradient bgGrad (colours::gridLight, 0.0f, 0.0f, + colours::panel.darker (0.06f), 0.0f, (float) height, false); + g.setGradientFill (bgGrad); g.fillRect (0, 0, width, height); g.setColour (colours::gridLight); g.drawRect (0.0f, 0.0f, (float) width, (float) height, 1.0f); @@ -383,12 +443,6 @@ StepPanel::StepPanel (MonostepAudioProcessor& processorRef) nextButton.onClick = [this] { setSelectedStep (selectedStep + 1); }; addAndMakeVisible (nextButton); - hintLabel.setText ("click a column in the grid or use the arrows to pick the step to fine-tune", juce::dontSendNotification); - hintLabel.setJustificationType (juce::Justification::centred); - hintLabel.setFont (font (8.5f)); - hintLabel.setColour (juce::Label::textColourId, colours::textDim); - addAndMakeVisible (hintLabel); - clearButton.setButtonText ("Clear Pattern"); clearButton.onClick = [this] { clearPattern(); }; addAndMakeVisible (clearButton); @@ -453,10 +507,23 @@ StepPanel::StepPanel (MonostepAudioProcessor& processorRef) for (int i = 0; i < RandomGroupCount; ++i) { randomizeToggles[i].setButtonText (groupNames[i]); - randomizeToggles[i].setToggleState (true, juce::dontSendNotification); + randomizeToggles[i].setToggleState (i == RandomPattern, juce::dontSendNotification); addAndMakeVisible (randomizeToggles[i]); } + resetFineButton.setButtonText ("Reset Fine"); + resetFineButton.onClick = [this] + { + for (int i = 0; i < numSteps; ++i) + processor.setStepCents (i, 0.0f); + }; + addAndMakeVisible (resetFineButton); + + retrigToggle.setButtonText ("Retrig"); + retrigToggle.setTooltip ("Restart the pattern from step 1 on each new note trigger"); + retrigAttachment = std::make_unique (processor.getAPVTS(), "seqRetrig", retrigToggle); + addAndMakeVisible (retrigToggle); + setSelectedStep (0); } @@ -524,8 +591,6 @@ void StepPanel::resized() prevButton.setBounds (knobX - 32, btnY, 24, 24); nextButton.setBounds (knobX + 74 + 8, btnY, 24, 24); - hintLabel.setBounds (8, btnY + 30, w - 16, 14); - const int shiftW = 22; shiftLeftButton.setBounds (10, btnY + 50, shiftW, 26); clearButton.setBounds (10 + shiftW + 4, btnY + 50, w - 20 - shiftW * 2 - 8, 26); @@ -545,6 +610,10 @@ void StepPanel::resized() const int row = i % 3; randomizeToggles[i].setBounds (10 + col * (colW + 4), btnY + 180 + row * (rowH + 2), colW, rowH); } + + const int retrigW = 100; + retrigToggle.setBounds (10, btnY + 252, retrigW, 26); + resetFineButton.setBounds (10 + retrigW + 4, btnY + 252, w - 20 - retrigW - 4, 26); } //============================================================================== @@ -571,6 +640,11 @@ MonostepAudioProcessorEditor::MonostepAudioProcessorEditor (MonostepAudioProcess decayKnob ("Decay"), sustainKnob ("Sustain"), releaseKnob ("Release"), + fAttackKnob ("Attack"), + fDecayKnob ("Decay"), + fSustainKnob ("Sustain"), + fReleaseKnob ("Release"), + fAmountKnob ("Amt"), driveKnob ("Drive"), ringKnob ("Ring"), accentKnob ("Accent"), @@ -592,41 +666,58 @@ MonostepAudioProcessorEditor::MonostepAudioProcessorEditor (MonostepAudioProcess content.addAndMakeVisible (octaveBar); content.addAndMakeVisible (zoomBar); - static const juce::StringArray waveformChoices = + const juce::StringArray waveformChoices = { "Sine", "Triangle", "Saw", "Square", "Pulse", "Noise", "Sub", "Pluck", "Super", "S&H", "Formant", "PWM" }; - auto setupWaveCombo = [&apvts, &waveformChoices] (juce::ComboBox& combo, - const juce::String& paramId) + auto setupWaveCombo = [&waveformChoices] (juce::ComboBox& combo) { combo.setColour (juce::ComboBox::textColourId, colours::text); combo.setColour (juce::ComboBox::outlineColourId, colours::gridLight); combo.setColour (juce::ComboBox::backgroundColourId, colours::grid); combo.setColour (juce::ComboBox::arrowColourId, colours::accent); - combo.setFont (font (11.0f)); for (int i = 0; i < waveformChoices.size(); ++i) combo.addItem (waveformChoices[i], i + 1); - - combo.selectId (static_cast (apvts.getRawParameterValue (paramId)->load()) + 1); }; - setupWaveCombo (wave1Box, "osc1Wave"); - setupWaveCombo (wave2Box, "osc2Wave"); + setupWaveCombo (wave1Box); + setupWaveCombo (wave2Box); wave1Attachment = std::make_unique (apvts, "osc1Wave", wave1Box); wave2Attachment = std::make_unique (apvts, "osc2Wave", wave2Box); + // filter type dropdown (below the two OSC dropdowns) + const juce::StringArray filterTypeChoices = + { + "12 LP", "12 HP", "12 BP", "12 Notch", + "24 LP", "24 HP", "24 BP", "24 Notch", + "48 LP", "48 HP", "48 BP", "48 Notch" + }; + + for (int i = 0; i < filterTypeChoices.size(); ++i) + filterTypeBox.addItem (filterTypeChoices[i], i + 1); + + filterTypeBox.setColour (juce::ComboBox::textColourId, colours::text); + filterTypeBox.setColour (juce::ComboBox::outlineColourId, colours::gridLight); + filterTypeBox.setColour (juce::ComboBox::backgroundColourId, colours::grid); + filterTypeBox.setColour (juce::ComboBox::arrowColourId, colours::accent); + + filterTypeAttachment = std::make_unique (apvts, "filterType", filterTypeBox); + content.addAndMakeVisible (wave1Box); content.addAndMakeVisible (wave2Box); + content.addAndMakeVisible (filterTypeBox); static constexpr float zoomValues[] = { 1.0f, 1.25f, 1.5f, 1.75f, 2.0f }; zoomBar.setManualHandler ([this] (int idx) { - setZoomFactor (zoomValues[juce::jlimit (0, 4, idx)]); + idx = juce::jlimit (0, 4, idx); + processorRef.setZoomIndex (idx); + setZoomFactor (zoomValues[idx]); }); auto addKnob = [&apvts, this] (Knob& knob, const juce::String& id, std::function fmt) @@ -651,10 +742,17 @@ MonostepAudioProcessorEditor::MonostepAudioProcessorEditor (MonostepAudioProcess addKnob (decayKnob, "decay", [] (float v) { return juce::String (v, 2) + "s"; }); addKnob (sustainKnob, "sustain", [] (float v) { return juce::String (v, 2); }); addKnob (releaseKnob, "release", [] (float v) { return juce::String (v, 2) + "s"; }); + addKnob (fAttackKnob, "filterAttack", [] (float v) { return juce::String (v, 2) + "s"; }); + addKnob (fDecayKnob, "filterDecay", [] (float v) { return juce::String (v, 2) + "s"; }); + addKnob (fSustainKnob, "filterSustain", [] (float v) { return juce::String (v, 2); }); + addKnob (fReleaseKnob, "filterRelease", [] (float v) { return juce::String (v, 2) + "s"; }); + addKnob (fAmountKnob, "filterEnvAmt", [] (float v) { return (v >= 0.0f ? "+" : "") + juce::String (v, 2) + " oct"; }); addKnob (driveKnob, "drive", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; }); addKnob (ringKnob, "ringmod", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; }); addKnob (accentKnob, "accent", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; }); + fAmountKnob.setDragSensitivity (800); + content.addAndMakeVisible (matrix); content.addAndMakeVisible (stepPanel); @@ -708,6 +806,10 @@ MonostepAudioProcessorEditor::MonostepAudioProcessorEditor (MonostepAudioProcess startTimerHz (30); refreshEditor(); + + const int savedZoom = processorRef.getZoomIndex(); + zoomBar.setSelectedIndex (savedZoom); + setZoomFactor (zoomValues[juce::jlimit (0, 4, savedZoom)]); } MonostepAudioProcessorEditor::~MonostepAudioProcessorEditor() @@ -761,8 +863,58 @@ void MonostepAudioProcessorEditor::paintContent (juce::Graphics& g) drawCaption (rateBar, "RATE"); drawCaption (octaveBar, "OCTAVE"); drawCaption (zoomBar, "ZOOM"); - drawCaption (wave1Box, "OSC 1"); - drawCaption (wave2Box, "OSC 2"); + + // Small labels inside the OSC section, left of the dropdowns. + g.setFont (font (8.5f)); + g.setColour (colours::textDim); + g.drawText ("OSC 1", wave1Box.getX() - oscLabelW, wave1Box.getY() + 5, oscLabelW - 6, 14, juce::Justification::centredRight); + g.drawText ("OSC 2", wave2Box.getX() - oscLabelW, wave2Box.getY() + 5, oscLabelW - 6, 14, juce::Justification::centredRight); + + g.setFont (font (9.0f)); + + // Union of two knob bounds, padded. + const auto groupBox = [] (const juce::Component& a, const juce::Component& b, float pad) + { + const auto ra = a.getBounds().toFloat(); + const auto rb = b.getBounds().toFloat(); + juce::Rectangle r; + r.setX (juce::jmin (ra.getX(), rb.getX()) - pad); + r.setY (juce::jmin (ra.getY(), rb.getY()) - pad); + r.setRight (juce::jmax (ra.getRight(), rb.getRight()) + pad); + r.setBottom (juce::jmax (ra.getBottom(), rb.getBottom()) + pad); + return r; + }; + + // Draw a knob group: subtle gradient behind the title legend + a slight border + // around the knobs themselves. leftPad extends the frame to the left so it can + // enclose side labels. + auto drawGroup = [&] (const juce::Component& first, const juce::Component& last, + const juce::String& title, float pad, float leftPad = 0.0f) + { + const int capX = first.getX() - (int) leftPad; + const int capW = last.getRight() - capX; + const int capY = first.getY() - 13; + + const juce::Rectangle legend (capX - 6.0f, capY - 4.0f, (float) capW + 12.0f, 12.0f); + g.setGradientFill (orangeGradient (legend)); + g.fillRoundedRectangle (legend, 3.0f); + + g.setFont (font (9.0f).boldened()); + g.setColour (colours::bg); + g.drawText (title, legend, juce::Justification::centred); + + auto box = groupBox (first, last, pad); + box.setLeft (box.getX() - leftPad); + g.setColour (colours::accent.withAlpha (0.30f)); + g.drawRoundedRectangle (box, 3.0f, 1.0f); + }; + + drawGroup (wave1Box, mixKnob, "OSC", 6.0f, (float) (oscLabelW - 21 + 9)); + drawGroup (glideKnob, accentKnob, "NOTE", 6.0f); + drawGroup (driveKnob, ringKnob, "DIST", 6.0f); + drawGroup (filterTypeBox, resKnob, "FILTER", 6.0f, 6.0f); + drawGroup (attackKnob, releaseKnob, "AMP ENV", 6.0f); + drawGroup (fAttackKnob, fAmountKnob, "FILTER ENV", 6.0f); const int footY = baseHeight - 20; g.setColour (colours::grid); @@ -801,7 +953,7 @@ void MonostepAudioProcessorEditor::resized() const int h = baseHeight; const int topBarH = 78; - const int bottomBarH = 96; + const int bottomBarH = 240; const int pad = 12; const int topBarY = (topBarH - 26) / 2; @@ -823,33 +975,60 @@ void MonostepAudioProcessorEditor::resized() const int bottomY = h - bottomBarH - pad; - int x = 16; - const int barW = 112; - - wave1Box.setBounds (x, bottomY + 30, barW, 24); x += barW + 8; - wave2Box.setBounds (x, bottomY + 30, barW, 24); x += barW + 16; - const int knobW = 56; - const int knobGap = 60; - const int knobY = bottomY + 2; + const int knobH = 82; + const int knobGap = 6; + const int step = knobW + knobGap; // left-edge advance per knob (62) - const auto placeKnob = [&] (Knob& knob) + const int oscColW = oscLabelW + oscDropW + oscGapW; // labels + dropdowns + gap to knobs + + // Both rows are centered over the full bottom bar width. + const int blockW = oscColW + 6 * step + knobW + 48; // +48 = the two group gaps + const int oscX = pad + ((w - 2 * pad) - blockW) / 2 + oscColW; + const int row1Y = bottomY + 24; + const int row2Y = row1Y + knobH + 34; // row 1 + a caption band + + // OSC 1 / OSC 2 dropdowns stacked inside the OSC section, after the labels. + const int oscDropX = oscX - oscColW + oscLabelW; + wave1Box.setBounds (oscDropX, row1Y, oscDropW, 24); + wave2Box.setBounds (oscDropX, row1Y + 30, oscDropW, 24); + + auto placeRow = [&] (int y, int startX, const std::initializer_list knobs) { - knob.setBounds (x, knobY, knobW, 82); - x += knobGap; + int cx = startX; + for (auto* k : knobs) + { + k->setBounds (cx, y, knobW, knobH); + cx += step; + } }; - placeKnob (coarseKnob); - placeKnob (detuneKnob); - placeKnob (mixKnob); - placeKnob (cutoffKnob); - placeKnob (resKnob); - placeKnob (glideKnob); - placeKnob (attackKnob); - placeKnob (decayKnob); - placeKnob (sustainKnob); - placeKnob (releaseKnob); - placeKnob (driveKnob); - placeKnob (ringKnob); - placeKnob (accentKnob); + // Row 1: coarse / detune / mix / glide / accent / drive / ring. + // NOTE and DIST are pushed apart to clear the group frames. + placeRow (row1Y, oscX, { &coarseKnob, &detuneKnob, &mixKnob }); + placeRow (row1Y, oscX + 3 * step + 24, { &glideKnob, &accentKnob }); + placeRow (row1Y, oscX + 5 * step + 48, { &driveKnob, &ringKnob }); + + // Row 2: Filter (dropdown + cutoff, res) [gap] Amp Envelope [gap] Filter Env. + // The three sections are centered as one block with equal frame gaps. + const int frameGap = 20; + const int framePad = 5; + auto frameW = [&] (int n) { return n * knobW + (n - 1) * knobGap + 2 * framePad; }; + + const int filterDropW = oscDropW; // FILTER TYPE dropdown width (matches the OSC dropdowns) + const int filterDropGap = 8; // gap between the dropdown and the filter knobs + const int filterSecW = filterDropW + filterDropGap + 2 * knobW + knobGap + 2 * framePad; + + const int row2W = filterSecW + frameGap + frameW (4) + frameGap + frameW (5); + const int row2Left = pad + ((w - 2 * pad) - row2W) / 2; + + const int filterDropX = row2Left + framePad; + const int filterKnobX = filterDropX + filterDropW + filterDropGap; + const int envStart = row2Left + filterSecW + frameGap + framePad; + const int fenvStart = row2Left + filterSecW + frameGap + frameW (4) + frameGap + framePad; + + filterTypeBox.setBounds (filterDropX, row2Y, filterDropW, 24); + placeRow (row2Y, filterKnobX, { &cutoffKnob, &resKnob }); + placeRow (row2Y, envStart, { &attackKnob, &decayKnob, &sustainKnob, &releaseKnob }); + placeRow (row2Y, fenvStart, { &fAttackKnob, &fDecayKnob, &fSustainKnob, &fReleaseKnob, &fAmountKnob }); } diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index 0df31a9..05af312 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -59,6 +59,8 @@ public: customOnValueChange = std::move (valueChangedFn); } + void setDragSensitivity (int pixels) { slider.setMouseDragSensitivity (pixels); } + void resized() override; private: @@ -89,6 +91,13 @@ public: void setManualHandler (std::function handler) { manualHandler = std::move (handler); } + void setSelectedIndex (int index) + { + index = juce::jlimit (0, juce::jmax (0, buttons.size() - 1), index); + for (int i = 0; i < buttons.size(); ++i) + buttons[i]->setToggleState (i == index, juce::dontSendNotification); + } + void resized() override; private: @@ -127,7 +136,6 @@ private: Knob fineKnob; juce::TextButton prevButton; juce::TextButton nextButton; - juce::Label hintLabel; juce::TextButton clearButton; juce::TextButton shiftLeftButton; juce::TextButton shiftRightButton; @@ -149,6 +157,11 @@ private: }; juce::ToggleButton randomizeToggles[RandomGroupCount]; + + juce::ToggleButton retrigToggle; + std::unique_ptr retrigAttachment; + + juce::TextButton resetFineButton; }; class MonostepAudioProcessorEditor final : public juce::AudioProcessorEditor, @@ -182,7 +195,7 @@ private: ContentComponent content; static constexpr int baseWidth = 1060; - static constexpr int baseHeight = 640; + static constexpr int baseHeight = 780; float zoomFactor = 1.0f; @@ -209,6 +222,11 @@ private: Knob decayKnob; Knob sustainKnob; Knob releaseKnob; + Knob fAttackKnob; + Knob fDecayKnob; + Knob fSustainKnob; + Knob fReleaseKnob; + Knob fAmountKnob; Knob driveKnob; Knob ringKnob; Knob accentKnob; @@ -218,8 +236,10 @@ private: ChoiceBar zoomBar; juce::ComboBox wave1Box; juce::ComboBox wave2Box; + juce::ComboBox filterTypeBox; std::unique_ptr wave1Attachment; std::unique_ptr wave2Attachment; + std::unique_ptr filterTypeAttachment; SequencerMatrix matrix; StepPanel stepPanel; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index 25cde3f..2b37e20 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -9,9 +9,11 @@ static const char* paramId (int index) static const char* ids[] = { "osc1Wave", "osc2Wave", "osc1Coarse", "osc2Coarse", "detune", "mix", - "cutoff", "res", "attack", "decay", "sustain", "release", "glide", "master", + "cutoff", "res", "filterType", "attack", "decay", "sustain", "release", "glide", "master", "seqLen", "seqRate", "seqRoot", "seqOctave", "seqSwing", "seqGateLen", - "drive", "ringmod", "accent", "fine1" + "drive", "ringmod", "accent", "fine1", + "filterAttack", "filterDecay", "filterSustain", "filterRelease", "filterEnvAmt", + "seqRetrig" }; return ids[index]; } @@ -19,9 +21,11 @@ static const char* paramId (int index) enum ParamIndex { pOsc1Wave, pOsc2Wave, pOsc1Coarse, pOsc2Coarse, pDetune, pMix, - pCutoff, pRes, pAttack, pDecay, pSustain, pRelease, pGlide, pMaster, + pCutoff, pRes, pFilterType, pAttack, pDecay, pSustain, pRelease, pGlide, pMaster, pSeqLen, pSeqRate, pSeqRoot, pSeqOctave, pSeqSwing, pSeqGateLen, pDrive, pRingMod, pAccent, pFine1, + pFilterAttack, pFilterDecay, pFilterSustain, pFilterRelease, pFilterEnvAmt, + pSeqRetrig, numParams }; @@ -39,10 +43,16 @@ MonostepAudioProcessor::MonostepAudioProcessor() osc1PhaseParam = apvts.getRawParameterValue (paramId (pFine1)); cutoffParam = apvts.getRawParameterValue (paramId (pCutoff)); resParam = apvts.getRawParameterValue (paramId (pRes)); + filterTypeParam = apvts.getRawParameterValue (paramId (pFilterType)); attackParam = apvts.getRawParameterValue (paramId (pAttack)); decayParam = apvts.getRawParameterValue (paramId (pDecay)); sustainParam = apvts.getRawParameterValue (paramId (pSustain)); releaseParam = apvts.getRawParameterValue (paramId (pRelease)); + filterAttackParam = apvts.getRawParameterValue (paramId (pFilterAttack)); + filterDecayParam = apvts.getRawParameterValue (paramId (pFilterDecay)); + filterSustainParam = apvts.getRawParameterValue (paramId (pFilterSustain)); + filterReleaseParam = apvts.getRawParameterValue (paramId (pFilterRelease)); + filterEnvAmtParam = apvts.getRawParameterValue (paramId (pFilterEnvAmt)); glideParam = apvts.getRawParameterValue (paramId (pGlide)); masterParam = apvts.getRawParameterValue (paramId (pMaster)); seqLenParam = apvts.getRawParameterValue (paramId (pSeqLen)); @@ -54,6 +64,7 @@ MonostepAudioProcessor::MonostepAudioProcessor() driveParam = apvts.getRawParameterValue (paramId (pDrive)); ringModParam = apvts.getRawParameterValue (paramId (pRingMod)); accentParam = apvts.getRawParameterValue (paramId (pAccent)); + seqRetrigParam = apvts.getRawParameterValue (paramId (pSeqRetrig)); setDefaultPattern(); } @@ -84,7 +95,13 @@ juce::AudioProcessorValueTreeState::ParameterLayout MonostepAudioProcessor::crea juce::AudioParameterFloatAttributes().withLabel ("Hz"))); layout.add (std::make_unique (paramId (pRes), - "Filter Res", juce::NormalisableRange (0.0f, 1.0f), 0.15f)); + "Filter Res", juce::NormalisableRange (0.0f, 1.0f), 0.15f)); + + layout.add (std::make_unique (paramId (pFilterType), + "Filter Type", 0, + (int) monostep::SynthVoice::FilterType::numFilterTypes - 1, + 0)); + layout.add (std::make_unique (paramId (pAttack), "Attack", juce::NormalisableRange (0.001f, 2.0f, 0.001f, 0.3f), 0.005f, @@ -101,6 +118,25 @@ juce::AudioProcessorValueTreeState::ParameterLayout MonostepAudioProcessor::crea "Release", juce::NormalisableRange (0.01f, 4.0f, 0.001f, 0.3f), 0.4f, juce::AudioParameterFloatAttributes().withLabel ("s"))); + layout.add (std::make_unique (paramId (pFilterAttack), + "Filter Env Attack", juce::NormalisableRange (0.001f, 2.0f, 0.001f, 0.3f), 0.005f, + juce::AudioParameterFloatAttributes().withLabel ("s"))); + + layout.add (std::make_unique (paramId (pFilterDecay), + "Filter Env Decay", juce::NormalisableRange (0.001f, 3.0f, 0.001f, 0.3f), 0.3f, + juce::AudioParameterFloatAttributes().withLabel ("s"))); + + layout.add (std::make_unique (paramId (pFilterSustain), + "Filter Env Sustain", juce::NormalisableRange (0.0f, 1.0f), 0.7f)); + + layout.add (std::make_unique (paramId (pFilterRelease), + "Filter Env Release", juce::NormalisableRange (0.01f, 4.0f, 0.001f, 0.3f), 0.4f, + juce::AudioParameterFloatAttributes().withLabel ("s"))); + + layout.add (std::make_unique (paramId (pFilterEnvAmt), + "Filter Env Amount", juce::NormalisableRange (-4.0f, 4.0f), 1.0f, + juce::AudioParameterFloatAttributes().withLabel ("oct"))); + layout.add (std::make_unique (paramId (pGlide), "Glide", juce::NormalisableRange (0.0f, 1.0f, 0.001f, 0.5f), 0.12f, juce::AudioParameterFloatAttributes().withLabel ("s"))); @@ -124,6 +160,9 @@ juce::AudioProcessorValueTreeState::ParameterLayout MonostepAudioProcessor::crea layout.add (std::make_unique (paramId (pSeqGateLen), "Gate Length", juce::NormalisableRange (0.05f, 1.0f), 0.8f)); + layout.add (std::make_unique (paramId (pSeqRetrig), + "Restart Pattern on Note", false)); + layout.add (std::make_unique (paramId (pDrive), "Distortion Drive", juce::NormalisableRange (0.0f, 1.0f), 0.0f)); @@ -215,31 +254,32 @@ void MonostepAudioProcessor::applyPreset (int index) float cutoff; float res; float attack; float decay; float sustain; float release; float glide; float gateLen; float swing; int rate; int root; int octave; float drive; float ring; float accent; + float fAttack; float fDecay; float fSustain; float fRelease; float fAmt; }; static const Preset presets[] = { // --- gritty / glidy ----------------------------------------------------- - { 0, 1, 0, 15.0f, 0.60f, 4200.0f, 0.15f, 0.02f, 0.25f, 0.70f, 0.35f, 0.25f, 0.80f, 0.10f, 2, 0, 3, 0.55f, 0.00f, 0.5f }, // Wet Tape - { 2, 0, -12, -8.0f, 0.45f, 1100.0f, 0.65f, 0.003f, 0.40f, 0.10f, 0.08f, 0.12f, 0.90f, 0.00f, 3, 0, 3, 0.85f, 0.00f, 0.4f }, // Acid Wash - { 3, 3, 0, 5.0f, 0.50f, 380.0f, 0.30f, 0.005f, 0.10f, 0.40f, 0.10f, 0.20f, 0.95f, 0.05f, 3, 0, 3, 0.60f, 0.00f, 0.3f }, // Tin Can - { 0, 3, 7, 25.0f, 0.35f, 2500.0f, 0.20f, 0.04f, 0.20f, 0.30f, 0.60f, 0.45f, 0.85f, 0.25f, 2, 0, 3, 0.40f, 0.00f, 0.5f }, // Bent Toy - { 2, 3, 0, 0.0f, 0.55f, 650.0f, 0.45f, 0.002f, 0.15f, 0.20f, 0.05f, 0.10f, 0.90f, 0.10f, 3, 0, 3, 0.95f, 0.60f, 0.3f }, // Broken Radio - { 1, 2, 0, 20.0f, 0.50f, 6000.0f, 0.10f, 0.003f, 0.15f, 0.80f, 0.20f, 0.10f, 0.90f, 0.15f, 3, 0, 3, 0.20f, 0.00f, 0.6f }, // Neon Arcade - { 0, 1, -12, -35.0f, 0.55f, 1800.0f, 0.40f, 0.10f, 0.35f, 0.20f, 0.80f, 0.50f, 0.70f, 0.20f, 2, 0, 3, 0.00f, 0.00f, 0.5f }, // Glitch Garden - { 2, 2, 5, 10.0f, 0.40f, 3200.0f, 0.35f, 0.002f, 0.20f, 0.60f, 0.12f, 0.15f, 0.95f, 0.40f, 3, 0, 3, 0.70f, 0.20f, 0.5f }, // Robo Funk - { 1, 1, -7, -15.0f, 0.50f, 2600.0f, 0.20f, 0.03f, 0.30f, 0.35f, 0.60f, 0.60f, 0.80f, 0.05f, 2, 0, 3, 0.10f, 0.00f, 0.4f }, // Lo-Fi Noir - { 0, 2, 0, 8.0f, 0.60f, 7000.0f, 0.08f, 0.01f, 0.20f, 0.90f, 0.25f, 0.30f, 0.75f, 0.00f, 3, 0, 3, 0.35f, 0.35f, 0.6f }, // Spectral Sweep - { 3, 0, -12, 12.0f, 0.45f, 5000.0f, 0.25f, 0.02f, 0.50f, 0.70f, 0.30f, 0.40f, 0.60f, 0.20f, 2, 0, 3, 0.30f, 0.15f, 0.5f }, // Plasma Organ - { 0, 0, 0, 0.0f, 0.50f, 900.0f, 0.50f, 0.05f, 0.30f, 0.50f, 0.50f, 0.35f, 0.85f, 0.30f, 2, 0, 3, 0.25f, 0.55f, 0.3f }, // Underwater FM + { 0, 1, 0, 15.0f, 0.60f, 4200.0f, 0.15f, 0.02f, 0.25f, 0.70f, 0.35f, 0.25f, 0.80f, 0.10f, 2, 0, 3, 0.55f, 0.00f, 0.5f, 0.02f, 0.30f, 0.60f, 0.25f, 1.5f }, // Wet Tape + { 2, 0, -12, -8.0f, 0.45f, 1100.0f, 0.65f, 0.003f, 0.40f, 0.10f, 0.08f, 0.12f, 0.90f, 0.00f, 3, 0, 3, 0.85f, 0.00f, 0.4f, 0.005f, 0.35f, 0.10f, 0.10f, 3.0f }, // Acid Wash + { 3, 3, 0, 5.0f, 0.50f, 380.0f, 0.30f, 0.005f, 0.10f, 0.40f, 0.10f, 0.20f, 0.95f, 0.05f, 3, 0, 3, 0.60f, 0.00f, 0.3f, 0.005f, 0.15f, 0.30f, 0.15f, 0.5f }, // Tin Can + { 0, 3, 7, 25.0f, 0.35f, 2500.0f, 0.20f, 0.04f, 0.20f, 0.30f, 0.60f, 0.45f, 0.85f, 0.25f, 2, 0, 3, 0.40f, 0.00f, 0.5f, 0.03f, 0.25f, 0.50f, 0.40f, 1.0f }, // Bent Toy + { 2, 3, 0, 0.0f, 0.55f, 650.0f, 0.45f, 0.002f, 0.15f, 0.20f, 0.05f, 0.10f, 0.90f, 0.10f, 3, 0, 3, 0.95f, 0.60f, 0.3f, 0.005f, 0.20f, 0.40f, 0.15f, 1.5f }, // Broken Radio + { 1, 2, 0, 20.0f, 0.50f, 6000.0f, 0.10f, 0.003f, 0.15f, 0.80f, 0.20f, 0.10f, 0.90f, 0.15f, 3, 0, 3, 0.20f, 0.00f, 0.6f, 0.01f, 0.30f, 0.70f, 0.30f, 1.0f }, // Neon Arcade + { 0, 1, -12, -35.0f, 0.55f, 1800.0f, 0.40f, 0.10f, 0.35f, 0.20f, 0.80f, 0.50f, 0.70f, 0.20f, 2, 0, 3, 0.00f, 0.00f, 0.5f, 0.01f, 0.40f, 0.50f, 0.20f, -1.5f }, // Glitch Garden + { 2, 2, 5, 10.0f, 0.40f, 3200.0f, 0.35f, 0.002f, 0.20f, 0.60f, 0.12f, 0.15f, 0.95f, 0.40f, 3, 0, 3, 0.70f, 0.20f, 0.5f, 0.01f, 0.25f, 0.60f, 0.20f, 2.0f }, // Robo Funk + { 1, 1, -7, -15.0f, 0.50f, 2600.0f, 0.20f, 0.03f, 0.30f, 0.35f, 0.60f, 0.60f, 0.80f, 0.05f, 2, 0, 3, 0.10f, 0.00f, 0.4f, 0.02f, 0.30f, 0.50f, 0.30f, -0.5f }, // Lo-Fi Noir + { 0, 2, 0, 8.0f, 0.60f, 7000.0f, 0.08f, 0.01f, 0.20f, 0.90f, 0.25f, 0.30f, 0.75f, 0.00f, 3, 0, 3, 0.35f, 0.35f, 0.6f, 0.01f, 0.30f, 0.20f, 0.30f, 3.5f }, // Spectral Sweep + { 3, 0, -12, 12.0f, 0.45f, 5000.0f, 0.25f, 0.02f, 0.50f, 0.70f, 0.30f, 0.40f, 0.60f, 0.20f, 2, 0, 3, 0.30f, 0.15f, 0.5f, 0.02f, 0.40f, 0.80f, 0.30f, 0.5f }, // Plasma Organ + { 0, 0, 0, 0.0f, 0.50f, 900.0f, 0.50f, 0.05f, 0.30f, 0.50f, 0.50f, 0.35f, 0.85f, 0.30f, 2, 0, 3, 0.25f, 0.55f, 0.3f, 0.05f, 0.30f, 0.50f, 0.40f, -2.0f }, // Underwater FM // --- clean / no glide / no drive --------------------------------------- - { 0, 0, 0, 0.0f, 0.50f, 7000.0f, 0.10f, 0.002f, 0.25f, 0.20f, 0.30f, 0.00f, 0.35f, 0.00f, 3, 0, 4, 0.00f, 0.00f, 0.6f }, // Glass Bells - { 1, 1, 0, 12.0f, 0.60f, 1200.0f, 0.25f, 0.30f, 1.20f, 0.80f, 1.50f, 0.20f, 1.00f, 0.10f, 2, 0, 2, 0.00f, 0.00f, 0.2f }, // Warm Pad - { 2, 2, 0, 5.0f, 0.50f, 2500.0f, 0.15f, 0.002f, 0.20f, 0.30f, 0.12f, 0.00f, 0.50f, 0.30f, 3, 0, 3, 0.00f, 0.00f, 0.5f }, // Pulse Groove - { 0, 3, 12, 0.0f, 0.40f, 1500.0f, 0.60f, 0.001f, 0.35f, 0.15f, 0.20f, 0.00f, 0.45f, 0.00f, 3, 0, 4, 0.00f, 0.35f, 0.7f }, // Steel Pluck - { 0, 0, 0, -5.0f, 0.40f, 500.0f, 0.20f, 0.01f, 0.40f, 0.90f, 0.30f, 0.00f, 0.90f, 0.00f, 2, 0, 1, 0.00f, 0.00f, 0.3f }, // Sub Rumble - { 1, 0, 0, 8.0f, 0.55f, 3000.0f, 0.20f, 0.005f, 0.30f, 0.60f, 0.25f, 0.30f, 0.70f, 0.15f, 2, 0, 3, 0.25f, 0.00f, 0.5f }, // Tape Bounce + { 0, 0, 0, 0.0f, 0.50f, 7000.0f, 0.10f, 0.002f, 0.25f, 0.20f, 0.30f, 0.00f, 0.35f, 0.00f, 3, 0, 4, 0.00f, 0.00f, 0.6f, 0.005f, 0.20f, 0.30f, 0.40f, 0.3f }, // Glass Bells + { 1, 1, 0, 12.0f, 0.60f, 1200.0f, 0.25f, 0.30f, 1.20f, 0.80f, 1.50f, 0.20f, 1.00f, 0.10f, 2, 0, 2, 0.00f, 0.00f, 0.2f, 0.50f, 1.00f, 0.70f, 1.20f, 1.0f }, // Warm Pad + { 2, 2, 0, 5.0f, 0.50f, 2500.0f, 0.15f, 0.002f, 0.20f, 0.30f, 0.12f, 0.00f, 0.50f, 0.30f, 3, 0, 3, 0.00f, 0.00f, 0.5f, 0.01f, 0.25f, 0.40f, 0.20f, 1.5f }, // Pulse Groove + { 0, 3, 12, 0.0f, 0.40f, 1500.0f, 0.60f, 0.001f, 0.35f, 0.15f, 0.20f, 0.00f, 0.45f, 0.00f, 3, 0, 4, 0.00f, 0.35f, 0.7f, 0.002f, 0.30f, 0.20f, 0.25f, 2.5f }, // Steel Pluck + { 0, 0, 0, -5.0f, 0.40f, 500.0f, 0.20f, 0.01f, 0.40f, 0.90f, 0.30f, 0.00f, 0.90f, 0.00f, 2, 0, 1, 0.00f, 0.00f, 0.3f, 0.01f, 0.30f, 0.60f, 0.30f, -1.0f }, // Sub Rumble + { 1, 0, 0, 8.0f, 0.55f, 3000.0f, 0.20f, 0.005f, 0.30f, 0.60f, 0.25f, 0.30f, 0.70f, 0.15f, 2, 0, 3, 0.25f, 0.00f, 0.5f, 0.01f, 0.30f, 0.50f, 0.30f, 1.0f }, // Tape Bounce }; const auto& pr = presets[juce::jlimit (0, getNumPresets() - 1, index)]; @@ -264,6 +304,11 @@ void MonostepAudioProcessor::applyPreset (int index) applyParamValue ("drive", pr.drive); applyParamValue ("ringmod", pr.ring); applyParamValue ("accent", pr.accent); + applyParamValue ("filterAttack", pr.fAttack); + applyParamValue ("filterDecay", pr.fDecay); + applyParamValue ("filterSustain", pr.fSustain); + applyParamValue ("filterRelease", pr.fRelease); + applyParamValue ("filterEnvAmt", pr.fAmt); } void MonostepAudioProcessor::randomizePattern() @@ -318,6 +363,11 @@ void MonostepAudioProcessor::randomizeFilter() { applyParamValue ("cutoff", 200.0f * std::pow (2.0f, random.nextFloat() * 5.0f)); applyParamValue ("res", random.nextFloat() * 0.8f); + applyParamValue ("filterEnvAmt", -3.0f + random.nextFloat() * 6.0f); + applyParamValue ("filterAttack", 0.001f * std::pow (500.0f, random.nextFloat())); + applyParamValue ("filterDecay", 0.05f * std::pow (30.0f, random.nextFloat())); + applyParamValue ("filterSustain", random.nextFloat()); + applyParamValue ("filterRelease", 0.05f * std::pow (40.0f, random.nextFloat())); } void MonostepAudioProcessor::randomizeEnv() @@ -375,15 +425,22 @@ void MonostepAudioProcessor::updateVoiceParams() monostep::SynthVoice::Params p; p.waveA = (monostep::Waveform) (int) osc1WaveParam->load(); p.waveB = (monostep::Waveform) (int) osc2WaveParam->load(); - p.detuneRatio = std::pow (2.0f, osc2CoarseParam->load() / 12.0f) - * std::pow (2.0f, detuneParam->load() / 1200.0f); + p.detuneRatioA = std::pow (2.0f, detuneParam->load() / 1200.0f); // OSC1: follows the knob + p.detuneRatio = std::pow (2.0f, osc2CoarseParam->load() / 12.0f) // OSC2: coarse + * std::pow (2.0f, -detuneParam->load() / 1200.0f); // OSC2: detune inverted p.mix = mixParam->load(); - p.cutoff = cutoffParam->load(); + p.cutoff = cutoffParam->load(); p.resonance = resParam->load(); + p.filterType = (monostep::SynthVoice::FilterType) (int) filterTypeParam->load(); p.attack = attackParam->load(); p.decay = decayParam->load(); p.sustain = sustainParam->load(); p.release = releaseParam->load(); + p.fAttack = filterAttackParam->load(); + p.fDecay = filterDecayParam->load(); + p.fSustain = filterSustainParam->load(); + p.fRelease = filterReleaseParam->load(); + p.fAmount = filterEnvAmtParam->load(); p.glide = glideParam->load(); p.master = masterParam->load(); p.drive = driveParam->load(); @@ -419,6 +476,7 @@ void MonostepAudioProcessor::processBlock (juce::AudioBuffer& buffer, juc const float cyclePpq = (float) seqLen * stepLen; // --- MIDI notes (gate/trigger for the sequencer) ------------------------- + bool noteOnSeen = false; for (const auto metadata : midiMessages) { const auto msg = metadata.getMessage(); @@ -426,6 +484,7 @@ void MonostepAudioProcessor::processBlock (juce::AudioBuffer& buffer, juc if (msg.isNoteOn()) { midiHeld = true; + noteOnSeen = true; lastMidiNote = msg.getNoteNumber(); } else if (msg.isNoteOff()) @@ -477,6 +536,16 @@ void MonostepAudioProcessor::processBlock (juce::AudioBuffer& buffer, juc lastGateApplied = false; } + // Optional retrigger: a fresh note-on restarts the pattern from + // step 1. Only honoured in free-run mode; while the host transport + // is running, the pattern follows the host grid instead. + if (noteOnSeen && seqRetrigParam->load() >= 0.5f) + { + freePpq = 0.0; + lastStep = -1; + lastGateApplied = false; + } + freePpq += ppqPerSample * numSamples; } } @@ -742,6 +811,7 @@ void MonostepAudioProcessor::getStateInformation (juce::MemoryBlock& destData) juce::ValueTree state = apvts.copyState(); juce::ValueTree seq = state.getOrCreateChildWithName ("SEQ", nullptr); storeSequence (seq); + state.setProperty ("zoom", zoomIndex, nullptr); std::unique_ptr xml (state.createXml()); copyXmlToBinary (*xml, destData); @@ -760,6 +830,8 @@ void MonostepAudioProcessor::setStateInformation (const void* data, int sizeInBy if (auto seq = state.getChildWithName ("SEQ"); seq.isValid()) loadSequence (seq); + zoomIndex = juce::jlimit (0, 4, (int) state.getProperty ("zoom", 0)); + if (onPatternChanged) onPatternChanged(); } diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h index 8f24828..ea228cf 100644 --- a/Source/PluginProcessor.h +++ b/Source/PluginProcessor.h @@ -32,6 +32,8 @@ public: void setStateInformation (const void*, int) override; juce::AudioProcessorValueTreeState& getAPVTS() { return apvts; } + void setZoomIndex (int index) { zoomIndex = juce::jlimit (0, 4, index); } + int getZoomIndex() const { return zoomIndex; } monostep::StepSequencer& getSequencer() { return sequencer; } const monostep::StepSequencer& getSequencer() const { return sequencer; } @@ -82,10 +84,16 @@ private: std::atomic* osc1PhaseParam = nullptr; std::atomic* cutoffParam = nullptr; std::atomic* resParam = nullptr; + std::atomic* filterTypeParam = nullptr; std::atomic* attackParam = nullptr; std::atomic* decayParam = nullptr; std::atomic* sustainParam = nullptr; std::atomic* releaseParam = nullptr; + std::atomic* filterAttackParam = nullptr; + std::atomic* filterDecayParam = nullptr; + std::atomic* filterSustainParam = nullptr; + std::atomic* filterReleaseParam = nullptr; + std::atomic* filterEnvAmtParam = nullptr; std::atomic* glideParam = nullptr; std::atomic* masterParam = nullptr; std::atomic* seqLenParam = nullptr; @@ -97,6 +105,7 @@ private: std::atomic* driveParam = nullptr; std::atomic* ringModParam = nullptr; std::atomic* accentParam = nullptr; + std::atomic* seqRetrigParam = nullptr; monostep::StepSequencer sequencer; monostep::SynthVoice voice; @@ -105,6 +114,8 @@ private: double sampleRate = 44100.0; + int zoomIndex = 0; + int lastStep = -1; std::atomic playStep { -1 }; bool lastGateApplied = false; diff --git a/Source/dsp/SynthVoice.h b/Source/dsp/SynthVoice.h index 0357f4b..4ef684b 100644 --- a/Source/dsp/SynthVoice.h +++ b/Source/dsp/SynthVoice.h @@ -9,6 +9,14 @@ namespace monostep class SynthVoice { public: + enum class FilterType + { + lp12 = 0, hp12, bp12, notch12, + lp24, hp24, bp24, notch24, + lp48, hp48, bp48, notch48, // 48 dB/octave = "2 x 24" + numFilterTypes + }; + struct Params { Waveform waveA = Waveform::saw; @@ -21,15 +29,22 @@ public: float osc2Phase = 0.0f; float cutoff = 7000.0f; float resonance = 0.15f; + FilterType filterType = FilterType::lp12; float attack = 0.005f; float decay = 0.3f; float sustain = 0.7f; float release = 0.4f; + float fAttack = 0.005f; + float fDecay = 0.3f; + float fSustain = 0.7f; + float fRelease = 0.4f; + float fAmount = 1.0f; // filter-env cutoff modulation in octaves float glide = 0.12f; float master = 0.9f; float drive = 0.0f; float ringMod = 0.0f; - float detuneRatio = 1.0f; + float detuneRatio = 1.0f; // OSC2 (inverted detune) + float detuneRatioA = 1.0f; // OSC1 (direct detune) float mix = 0.5f; }; @@ -42,7 +57,8 @@ public: void prepare (double sr) { sampleRate = (float) sr; - accentSmoothCoef = 1.0f - std::exp (-1.0f / (0.003f * sampleRate)); + accentAttackCoef = 1.0f - std::exp (-1.0f / (0.003f * sampleRate)); + accentReleaseCoef = 1.0f - std::exp (-1.0f / (0.050f * sampleRate)); reset(); } @@ -51,9 +67,15 @@ public: phaseA = phaseB = 0.0f; currentFreq = targetFreq = 440.0f; smoothing = 0.0f; - filterLow = filterBand = 0.0f; + for (int i = 0; i < 4; ++i) + { + filterLow[i] = 0.0f; + filterBand[i] = 0.0f; + } env = 0.0f; envStage = Stage::idle; + fenv = 0.0f; + fenvStage = Stage::idle; gate = false; accentLevel = 1.0f; accentSmooth = 1.0f; @@ -92,6 +114,9 @@ public: if (envStage != Stage::idle && envStage != Stage::release) envStage = Stage::release; + + if (fenvStage != Stage::idle && fenvStage != Stage::release) + fenvStage = Stage::release; } } @@ -109,6 +134,37 @@ private: void retriggerEnvelope() { envStage = Stage::attack; + fenvStage = Stage::attack; + } + + void updateFilterEnvelope() + { + const float sr = sampleRate; + + switch (fenvStage) + { + case Stage::attack: + fenv += 1.0f / (params.fAttack * sr); + if (fenv >= 1.0f) { fenv = 1.0f; fenvStage = Stage::decay; } + break; + + case Stage::decay: + fenv -= (1.0f - params.fSustain) / (params.fDecay * sr); + if (fenv <= params.fSustain) { fenv = params.fSustain; fenvStage = Stage::sustain; } + break; + + case Stage::sustain: + fenv = params.fSustain; + break; + + case Stage::release: + fenv -= 1.0f / (params.fRelease * sr); + if (fenv <= 0.0f) { fenv = 0.0f; fenvStage = Stage::idle; } + break; + + case Stage::idle: + break; + } } void updateEnvelope() @@ -155,27 +211,83 @@ private: } } - float processFilter (float input) + float processFilter (float input, float cutoff) { - const float f = 2.0f * std::sin (juce::MathConstants::pi * params.cutoff / sampleRate); - const float q = 1.0f / (1.0f + params.resonance * 9.0f); + const int numStages = numFilterStages(); + const float f = 2.0f * std::sin (juce::MathConstants::pi * cutoff / sampleRate); + // Distribute resonance across the pole stages (each stage gets res/stages) so the + // composite resonance matches the 12 dB case instead of stacking into self-oscillation + // near Nyquist. The 12 dB case (one stage) is unchanged. + const float q = 1.0f / (1.0f + params.resonance * 9.0f / (float) numStages); - filterLow += f * filterBand; - const float high = input - filterLow - q * filterBand; - filterBand += f * high; + // Always cascade the LP response (low -> low): this keeps the integrator chain + // unconditionally stable for every pole count. The requested response (LP/HP/BP/Notch) + // is then tapped from the final stage, which preserves the 12/24/48 slope while + // avoiding the ringing/unstability of cascading raw high/band signals. + float sig = input; + float low = 0.0f, high = 0.0f, band = 0.0f; - return filterLow; + for (int s = 0; s < numStages; ++s) + { + low = filterLow[s] + f * filterBand[s]; // new low (old band) + high = sig - low - q * filterBand[s]; // new high (new low, old band) + band = filterBand[s] + f * high; // new band + + // Safety clamp: prevents the resonant state from diverging into NaN when a + // high pole count is driven at max resonance near Nyquist. Normal signals + // (accents peak ~[1 + res*4]) never reach this bound. + band = juce::jlimit (-16.0f, 16.0f, band); + low = juce::jlimit (-16.0f, 16.0f, low); + + filterLow[s] = low; + filterBand[s] = band; + + sig = low; // cascade LP response + } + + return filterTap (low, high, band); // tap from last stage + } + + int numFilterStages() const + { + switch (params.filterType) + { + case FilterType::lp12: case FilterType::hp12: + case FilterType::bp12: case FilterType::notch12: return 1; + case FilterType::lp24: case FilterType::hp24: + case FilterType::bp24: case FilterType::notch24: return 2; + case FilterType::lp48: case FilterType::hp48: + case FilterType::bp48: case FilterType::notch48: return 4; + default: return 1; + } + return 1; + } + + float filterTap (float low, float high, float band) const + { + switch (params.filterType) + { + case FilterType::lp12: case FilterType::lp24: case FilterType::lp48: return low; + case FilterType::hp12: case FilterType::hp24: case FilterType::hp48: return high; + case FilterType::bp12: case FilterType::bp24: case FilterType::bp48: return band; + case FilterType::notch12: case FilterType::notch24: case FilterType::notch48: return low + high; + default: return low; + } + return low; } float renderSample() { updateEnvelope(); + updateFilterEnvelope(); updateGlide(); - // Smooth the accent gain so it ramps in/out instead of clicking. - accentSmooth += (accentLevel - accentSmooth) * accentSmoothCoef; + // Accent gains fast attack / slow release, so the boost fades out smoothly + // at the tail of an accented note instead of cutting off hard-edged. + accentSmooth += (accentLevel - accentSmooth) + * (accentLevel > accentSmooth ? accentAttackCoef : accentReleaseCoef); - const float incA = currentFreq / sampleRate; + const float incA = (currentFreq * params.detuneRatioA) / sampleRate; const float incB = (currentFreq * params.detuneRatio) / sampleRate; const float a = renderWave (params.waveA, phaseA, incA); @@ -190,7 +302,9 @@ private: out = (1.0f - params.ringMod) * out + params.ringMod * (a * b); const float preFilter = out; - out = processFilter (out); + const float envCutoff = juce::jlimit (20.0f, 20000.0f, + params.cutoff * std::pow (2.0f, params.fAmount * fenv)); + out = processFilter (out, envCutoff); // Accented steps also get a touch of the unfiltered signal so they cut // through the mix (presence), not just a volume bump. @@ -215,15 +329,18 @@ private: float targetFreq = 440.0f; float smoothing = 0.0f; - float filterLow = 0.0f; - float filterBand = 0.0f; + float filterLow[4] = {}; // SVF stage states (up to 4 poles for 48 dB) + float filterBand[4] = {}; float env = 0.0f; Stage envStage = Stage::idle; + float fenv = 0.0f; + Stage fenvStage = Stage::idle; bool gate = false; float accentLevel = 1.0f; float accentSmooth = 1.0f; - float accentSmoothCoef = 0.01f; + float accentAttackCoef = 0.0f; // fast charge when an accent starts + float accentReleaseCoef = 0.01f; // slow release -> boost fades out at note end }; } // namespace monostep diff --git a/Source/dsp/WaveTables.h b/Source/dsp/WaveTables.h index f24532a..2bf1ee3 100644 --- a/Source/dsp/WaveTables.h +++ b/Source/dsp/WaveTables.h @@ -103,7 +103,6 @@ inline float renderWave (Waveform w, float phase, float inc) case Waveform::super: { - // A small detuned stack of band-limited saws -> fat, chorus-like lead. float sum = 0.0f; const float detunes[3] = { -0.01f, 0.0f, 0.01f }; @@ -118,8 +117,6 @@ inline float renderWave (Waveform w, float phase, float inc) case Waveform::sah: { - // Sample & hold: a stepped random level per fraction of the cycle. - // Deterministic (no shared state) so each oscillator is independent. const int steps = 16; const float f = frac (phase); int s = static_cast (std::floor (f * (float) steps)); @@ -135,7 +132,6 @@ inline float renderWave (Waveform w, float phase, float inc) case Waveform::formant: { - // Two formant-like bands via slow AM sidebands (fixed ratios). const float w = phase * 6.283185307179586f; const float env = 0.5f + 0.3f * std::cos (w * 0.5f) + 0.2f * std::cos (w * 0.25f); return std::sin (w) * env; @@ -143,7 +139,6 @@ inline float renderWave (Waveform w, float phase, float inc) case Waveform::pwm: { - // Fixed 20 % pulse, band-limited at both edges. const float width = 0.2f; const float f = frac (phase); float v = (f < width) ? 1.0f : -1.0f; diff --git a/Source/ui/SequencerMatrix.cpp b/Source/ui/SequencerMatrix.cpp index 264b257..7e6bc5a 100644 --- a/Source/ui/SequencerMatrix.cpp +++ b/Source/ui/SequencerMatrix.cpp @@ -66,6 +66,22 @@ void SequencerMatrix::paint (juce::Graphics& g) const float ox = (float) bounds.getX(); const float oy = (float) bounds.getY(); + // Slightly-3D header band above the grid. + juce::ColourGradient headerGrad (colours::gridLight, + 0.0f, 0.0f, + colours::panel.darker (0.10f), + 0.0f, headerH, false); + g.setGradientFill (headerGrad); + g.fillRect (0.0f, 0.0f, (float) getWidth(), headerH); + + g.setColour (colours::gridLight.brighter (0.3f).withAlpha (0.4f)); + g.fillRect (0.0f, 0.0f, (float) getWidth(), 1.0f); + + g.setColour (colours::grid); + g.drawHorizontalLine (juce::roundToInt (headerH) - 1, 0, (float) getWidth()); + g.setColour (colours::bg); + g.drawHorizontalLine (juce::roundToInt (headerH), 0, (float) getWidth()); + // grid background g.setColour (colours::bg); g.fillRect (bounds.toFloat()); @@ -156,28 +172,21 @@ void SequencerMatrix::paint (juce::Graphics& g) const bool isSelected = (i == selectedStep); const float radius = 3.0f; - // cell body - juce::ColourGradient grad (colours::accent, - cell.getTopLeft().translated (cell.getWidth() * 0.5f, 0.0f), - isSelected ? colours::accent.brighter (0.12f) : colours::accent.darker (0.35f), - cell.getBottomLeft().translated (cell.getWidth() * 0.5f, 0.0f), - false); + // cell body: keep a visible top->bottom gradient in both states. The selected + // note is brighter overall, but still holds its gradient (a flat bright fill + // would read as "no gradient"). + const juce::Colour noteTop = isSelected ? colours::accent.brighter (0.2f) : colours::accent; + const juce::Colour noteBottom = colours::accent.darker (0.3f); + + juce::ColourGradient grad (noteTop, + cell.getTopLeft().translated (cell.getWidth() * 0.5f, 0.0f), + noteBottom, + cell.getBottomLeft().translated (cell.getWidth() * 0.5f, 0.0f), + false); g.setGradientFill (grad); g.fillRoundedRectangle (cell, radius); - // slide arrow - if (s.slide) - { - const float ax = cell.getX() + cell.getWidth() * 0.5f; - const float ay = cell.getY() + cell.getHeight() - 4.0f; - - g.setColour (colours::selected); - juce::Path tri; - tri.addTriangle (ax - 3.5f, ay - 1.5f, ax + 3.5f, ay - 1.5f, ax, ay + 3.5f); - g.fillPath (tri); - } - // fine-tune indicator if (std::fabs (s.cents) > 0.5f) { @@ -206,9 +215,15 @@ void SequencerMatrix::paint (juce::Graphics& g) continue; const auto cell = juce::Rectangle (ox + i * cellW + 1.5f, slideY + 1.5f, - cellW - 3.0f, cellH - 3.0f); + cellW - 3.0f, cellH - 3.0f); - g.setColour (colours::accent); + // match the gradient style of the note cells + juce::ColourGradient grad (colours::accent, + cell.getTopLeft().translated (cell.getWidth() * 0.5f, 0.0f), + colours::accent.darker (0.3f), + cell.getBottomLeft().translated (cell.getWidth() * 0.5f, 0.0f), + false); + g.setGradientFill (grad); g.fillRoundedRectangle (cell, 3.0f); const float ax = cell.getX() + cell.getWidth() * 0.5f; @@ -241,7 +256,15 @@ void SequencerMatrix::paint (juce::Graphics& g) const auto cell = juce::Rectangle (ox + i * cellW + 1.5f, accentY + 1.5f, cellW - 3.0f, cellH - 3.0f); - g.setColour (colours::accent2); + const bool isSelected = (i == selectedStep); + + juce::ColourGradient agrad (isSelected ? colours::accent2.brighter (0.2f) : colours::accent2, + cell.getTopLeft().translated (cell.getWidth() * 0.5f, 0.0f), + colours::accent2.darker (0.3f), + cell.getBottomLeft().translated (cell.getWidth() * 0.5f, 0.0f), + false); + + g.setGradientFill (agrad); g.fillRoundedRectangle (cell, 3.0f); const float ax = cell.getX() + cell.getWidth() * 0.5f;