From e52e5cb1617b03460e6530742da66884032ef8a7 Mon Sep 17 00:00:00 2001 From: armin Date: Wed, 5 Aug 2026 19:55:57 +0200 Subject: [PATCH] MonoStep: monophonic 2-oscillator step-sequencer synth --- .gitignore | 38 +++ CMakeLists.txt | 97 ++++++ Source/BuildInfo.h | 48 +++ Source/PluginEditor.cpp | 613 ++++++++++++++++++++++++++++++++++ Source/PluginEditor.h | 190 +++++++++++ Source/PluginProcessor.cpp | 516 ++++++++++++++++++++++++++++ Source/PluginProcessor.h | 98 ++++++ Source/Theme.h | 44 +++ Source/dsp/StepSequencer.h | 56 ++++ Source/dsp/SynthVoice.h | 189 +++++++++++ Source/dsp/WaveTables.h | 71 ++++ Source/ui/SequencerMatrix.cpp | 412 +++++++++++++++++++++++ Source/ui/SequencerMatrix.h | 67 ++++ tests/TestHost.cpp | 235 +++++++++++++ 14 files changed, 2674 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 Source/BuildInfo.h create mode 100644 Source/PluginEditor.cpp create mode 100644 Source/PluginEditor.h create mode 100644 Source/PluginProcessor.cpp create mode 100644 Source/PluginProcessor.h create mode 100644 Source/Theme.h create mode 100644 Source/dsp/StepSequencer.h create mode 100644 Source/dsp/SynthVoice.h create mode 100644 Source/dsp/WaveTables.h create mode 100644 Source/ui/SequencerMatrix.cpp create mode 100644 Source/ui/SequencerMatrix.h create mode 100644 tests/TestHost.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2507f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Build output +/build/ +/_artefacts/ +/*_artefacts/ +*.vst3 +*.vst3.bundle +*.component +*.aaxplugin +*.app +*.lv2 + +# CMake +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +CMakeOutput.log +CMakeError.log +cmake-build-*/ +Makefile +*.ninja + +# Xcode +*.xcodeproj +*.xcworkspace +xcuserdata/ +DerivedData/ + +# IDE / editors +.vscode/ +.idea/ +.vs/ +*.sublime-project +*.sublime-workspace +*.swp +*~ + +# macOS +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..9a5c480 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,97 @@ +cmake_minimum_required(VERSION 3.22) + +project(MonoStep VERSION 1.0.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0") +set(CMAKE_OSX_ARCHITECTURES "arm64") + +# --- build metadata (git info + build date) ---------------------------------- +set(MONOSTEP_GIT_STRING "no-vcs") + +find_package(Git QUIET) +if(Git_FOUND) + execute_process( + COMMAND ${GIT_EXECUTABLE} -C "${CMAKE_CURRENT_LIST_DIR}" rev-parse --short HEAD + OUTPUT_VARIABLE MONOSTEP_GIT_HASH + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) + execute_process( + COMMAND ${GIT_EXECUTABLE} -C "${CMAKE_CURRENT_LIST_DIR}" status --porcelain + OUTPUT_VARIABLE MONOSTEP_GIT_PORCELAIN + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) + + if(MONOSTEP_GIT_HASH) + set(MONOSTEP_GIT_STRING "${MONOSTEP_GIT_HASH}") + if(NOT MONOSTEP_GIT_PORCELAIN STREQUAL "") + string(APPEND MONOSTEP_GIT_STRING "-dirty") + endif() + endif() +endif() + +if(NOT DEFINED JUCE_DIR) + set(JUCE_DIR "${CMAKE_CURRENT_LIST_DIR}/../juce") +endif() + +if(NOT EXISTS "${JUCE_DIR}/CMakeLists.txt") + message(FATAL_ERROR "JUCE not found at ${JUCE_DIR}. Pass -DJUCE_DIR= or clone it next to this project.") +endif() + +add_subdirectory("${JUCE_DIR}" juce_build) + +juce_add_plugin(MonoStep + VERSION 1.0.0 + COMPANY_NAME "armin" + BUNDLE_ID "com.armin.monostep" + FORMATS VST3 + PRODUCT_NAME "MonoStep" + PLUGIN_MANUFACTURER_CODE Armn + PLUGIN_CODE MStp + DESCRIPTION "Monophonic 2-oscillator step-sequencer synth" + EDITOR_NAME MonoStepAudioProcessorEditor + IS_SYNTH TRUE + NEEDS_MIDI_INPUT TRUE + VST3_AUTO_MANIFEST TRUE +) + +juce_generate_juce_header(MonoStep) + +target_sources(MonoStep PRIVATE + Source/PluginProcessor.cpp + Source/PluginEditor.cpp + Source/ui/SequencerMatrix.cpp +) + +target_include_directories(MonoStep PRIVATE Source) + +target_compile_definitions(MonoStep PRIVATE + JUCE_WEB_BROWSER=0 + JUCE_VST3_CAN_REPLACE_VST2=0 + MONOSTEP_GIT_INFO="${MONOSTEP_GIT_STRING}" +) + +option(MONOSTEP_BUILD_TESTS "Build the headless host test" ON) + +if(MONOSTEP_BUILD_TESTS) + juce_add_console_app(MonoStepTestHost + PRODUCT_NAME MonoStepTestHost + ) + juce_generate_juce_header(MonoStepTestHost) + target_link_libraries(MonoStepTestHost PRIVATE + juce::juce_audio_processors + juce::juce_audio_formats + ) + target_include_directories(MonoStepTestHost PRIVATE Source) + target_sources(MonoStepTestHost PRIVATE + Source/PluginProcessor.cpp + tests/TestHost.cpp + ) + target_compile_definitions(MonoStepTestHost PRIVATE + MONOSTEP_HEADLESS=1 + JUCE_PLUGINHOST_VST3=1 + ) +endif() diff --git a/Source/BuildInfo.h b/Source/BuildInfo.h new file mode 100644 index 0000000..1946af8 --- /dev/null +++ b/Source/BuildInfo.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +namespace monostep +{ + +namespace buildinfo +{ + +inline juce::String gitInfo() +{ +#if defined (MONOSTEP_GIT_INFO) + return juce::String (MONOSTEP_GIT_INFO); +#else + return "no-vcs"; +#endif +} + +inline juce::String buildDateString() +{ + static const char* months[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + + const juce::String d (__DATE__); + int month = 1; + + for (int i = 0; i < 12; ++i) + if (d.startsWith (months[i])) + { + month = i + 1; + break; + } + + const int day = d.substring (4, 6).trim().getIntValue(); + const int year = d.substring (7).getIntValue(); + + return juce::String::formatted ("%04d-%02d-%02d__%s", year, month, day, __TIME__); +} + +inline juce::String footerText() +{ + return gitInfo() + " (" + buildDateString() + ")"; +} + +} // namespace buildinfo + +} // namespace monostep diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..80fd08b --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,613 @@ +#include "PluginEditor.h" + +#include "PluginProcessor.h" +#include "Theme.h" +#include "BuildInfo.h" + +using namespace monostep; + +//============================================================================== +EditorLookAndFeel::EditorLookAndFeel() +{ + setColour (juce::Slider::textBoxBackgroundColourId, colours::panel); + setColour (juce::Slider::textBoxTextColourId, colours::text); + setColour (juce::Slider::textBoxOutlineColourId, colours::grid); +} + +void EditorLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int w, int h, + float sliderPos, float rotaryStartAngle, + float rotaryEndAngle, juce::Slider&) +{ + const auto bounds = juce::Rectangle ((float) x, (float) y, (float) w, (float) h); + const auto centre = bounds.getCentre(); + + const float radius = juce::jmin (bounds.getWidth(), bounds.getHeight()) * 0.5f; + + const float start = rotaryStartAngle; + 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); + + g.setColour (colours::gridLight); + g.drawEllipse (centre.getX() - radius, centre.getY() - radius, radius * 2.0f, radius * 2.0f, 1.0f); + + if (end - start < 6.0f) + { + juce::Path valueArc; + valueArc.addCentredArc (centre.getX(), centre.getY(), + radius * 0.85f, radius * 0.85f, + 0.0f, start, angle, true); + + g.setColour (colours::accent); + g.strokePath (valueArc, juce::PathStrokeType (2.5f)); + } + + // JUCE measures rotary angles clockwise from 12 o'clock; the pointer is + // drawn with the same convention so it matches the arc and mouse dragging. + const float tickLen = radius * 0.62f; + g.setColour (colours::accent); + g.drawLine (centre.getX(), centre.getY(), + centre.getX() + tickLen * std::sin (angle), + centre.getY() - tickLen * std::cos (angle), + 2.5f); + + g.fillEllipse (centre.getX() - 2.5f, centre.getY() - 2.5f, 5.0f, 5.0f); +} + +void EditorLookAndFeel::drawToggleButton (juce::Graphics& g, juce::ToggleButton& button, + bool, bool) +{ + const auto bounds = button.getLocalBounds().toFloat(); + + g.setColour (button.getToggleState() ? colours::accent : colours::grid); + g.fillRoundedRectangle (bounds, 4.0f); + + g.setFont (font (11.0f)); + g.setColour (button.getToggleState() ? colours::bg : colours::text); + g.drawText (button.getButtonText(), bounds, juce::Justification::centred); +} + +void EditorLookAndFeel::drawButtonBackground (juce::Graphics& g, juce::Button& button, + const juce::Colour&, bool highlighted, bool) +{ + const auto bounds = button.getLocalBounds().toFloat().reduced (0.5f); + + if (button.getToggleState()) + g.setColour (colours::accent); + else + g.setColour (highlighted ? colours::gridLight : colours::grid); + + g.fillRoundedRectangle (bounds, 4.0f); +} + +void EditorLookAndFeel::drawButtonText (juce::Graphics& g, juce::TextButton& button, bool, bool) +{ + g.setFont (font (10.5f)); + g.setColour (button.getToggleState() ? colours::bg : colours::text); + g.drawText (button.getButtonText(), button.getLocalBounds(), juce::Justification::centred); +} + +//============================================================================== +Knob::Knob (const juce::String& title) +{ + slider.setSliderStyle (juce::Slider::RotaryVerticalDrag); + slider.setRotaryParameters (juce::MathConstants::pi * 1.25f, + juce::MathConstants::pi * 2.75f, + true); + slider.setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0); + slider.onValueChange = [this] + { + updateValueLabel(); + + if (customOnValueChange != nullptr) + customOnValueChange ((float) slider.getValue()); + }; + + addAndMakeVisible (slider); + + titleLabel.setText (title, juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centred); + titleLabel.setFont (font (10.0f)); + titleLabel.setColour (juce::Label::textColourId, colours::text); + addAndMakeVisible (titleLabel); + + valueLabel.setJustificationType (juce::Justification::centred); + valueLabel.setFont (font (9.0f)); + valueLabel.setColour (juce::Label::textColourId, colours::accent); + addAndMakeVisible (valueLabel); + + updateValueLabel(); +} + +void Knob::resized() +{ + const int w = getWidth(); + + titleLabel.setBounds (0, 0, w, 14); + slider.setBounds (4, 15, w - 8, w - 8); + valueLabel.setBounds (0, w - 8 + 15, w, 12); +} + +void Knob::updateValueLabel() +{ + const float value = slider.getValue(); + + if (format) + valueLabel.setText (format (value), juce::dontSendNotification); + else + valueLabel.setText (juce::String (value, 2), juce::dontSendNotification); +} + +//============================================================================== +ChoiceBar::ChoiceBar (juce::AudioProcessorValueTreeState& apvtsRef, + const juce::String& id, + const juce::StringArray& choices) + : apvts (&apvtsRef), paramId (id) +{ + for (int i = 0; i < choices.size(); ++i) + { + auto* button = buttons.add (new juce::TextButton (choices[i])); + + button->setClickingTogglesState (true); + button->setRadioGroupId (1); + button->onClick = [this, i] { setIndex (i); }; + + addAndMakeVisible (button); + } + + apvts->addParameterListener (paramId, this); + refreshFromParam(); +} + +ChoiceBar::ChoiceBar (const juce::StringArray& choices, + std::function onSelected) + : manualHandler (std::move (onSelected)) +{ + for (int i = 0; i < choices.size(); ++i) + { + auto* button = buttons.add (new juce::TextButton (choices[i])); + + button->setClickingTogglesState (true); + button->setRadioGroupId (1); + button->onClick = [this, i] { setIndex (i); }; + + addAndMakeVisible (button); + } + + buttons.getFirst()->setToggleState (true, juce::dontSendNotification); +} + +ChoiceBar::~ChoiceBar() +{ + if (apvts != nullptr) + apvts->removeParameterListener (paramId, this); +} + +void ChoiceBar::parameterChanged (const juce::String&, float) +{ + juce::MessageManager::callAsync ([this] { refreshFromParam(); }); +} + +void ChoiceBar::refreshFromParam() +{ + if (apvts == nullptr) + return; + + if (auto* parameter = apvts->getParameter (paramId)) + { + const int index = juce::roundToInt (parameter->convertFrom0to1 (parameter->getValue())); + + for (int i = 0; i < buttons.size(); ++i) + buttons[i]->setToggleState (i == index, juce::dontSendNotification); + } +} + +void ChoiceBar::setIndex (int index) +{ + if (apvts != nullptr) + { + if (auto* parameter = apvts->getParameter (paramId)) + { + parameter->beginChangeGesture(); + parameter->setValueNotifyingHost (parameter->convertTo0to1 ((float) index)); + parameter->endChangeGesture(); + } + + return; + } + + if (manualHandler != nullptr) + manualHandler (index); +} + +void ChoiceBar::resized() +{ + const int count = buttons.size(); + const float spacing = 3.0f; + const float w = ((float) getWidth() - spacing * (count - 1)) / (float) count; + + for (int i = 0; i < count; ++i) + buttons[i]->setBounds (juce::roundToInt (i * (w + spacing)), 0, + juce::roundToInt (w), getHeight()); +} + +//============================================================================== +StepPanel::StepPanel (MonoStepAudioProcessor& processorRef) + : processor (processorRef), + fineKnob ("Fine") +{ + titleLabel.setText ("Step", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centred); + titleLabel.setFont (font (12.0f)); + titleLabel.setColour (juce::Label::textColourId, colours::text); + addAndMakeVisible (titleLabel); + + noteLabel.setJustificationType (juce::Justification::centred); + noteLabel.setFont (font (20.0f)); + noteLabel.setColour (juce::Label::textColourId, colours::accent); + addAndMakeVisible (noteLabel); + + gateLabel.setJustificationType (juce::Justification::centred); + gateLabel.setFont (font (10.0f)); + gateLabel.setColour (juce::Label::textColourId, colours::textDim); + addAndMakeVisible (gateLabel); + + auto& fineSlider = fineKnob.getSlider(); + fineSlider.setRange (-50.0, 50.0, 1.0); + fineSlider.setValue (0.0, juce::dontSendNotification); + fineKnob.setFormat ([] (float v) { return juce::String (v, 0) + " ct"; }); + fineKnob.setOnValueChanged ([this] (float v) + { + if (! updating) + processor.setStepCents (selectedStep, v); + }); + + addAndMakeVisible (fineKnob); + + prevButton.setButtonText ("<"); + prevButton.onClick = [this] { setSelectedStep (selectedStep - 1); }; + addAndMakeVisible (prevButton); + + nextButton.setButtonText (">"); + 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); + + setSelectedStep (0); +} + +void StepPanel::setSelectedStep (int stepIdx) +{ + if (stepIdx < 0 || stepIdx >= numSteps) + return; + + if (stepIdx != selectedStep) + { + selectedStep = stepIdx; + + if (onStepSelected) + onStepSelected (stepIdx); + } + + refresh(); +} + +void StepPanel::refresh() +{ + const auto& s = processor.getSequencer().step (selectedStep); + + updating = true; + + titleLabel.setText ("Step " + juce::String (selectedStep + 1), juce::dontSendNotification); + noteLabel.setText (processor.getStepNoteName (selectedStep) + + (std::fabs (s.cents) > 0.5f ? " (" + formatCents (s.cents) + ")" : ""), + juce::dontSendNotification); + gateLabel.setText (s.gate ? "gate: on" : "gate: off", juce::dontSendNotification); + + fineKnob.getSlider().setValue (s.cents, juce::dontSendNotification); + + updating = false; +} + +void StepPanel::clearPattern() +{ + for (int i = 0; i < numSteps; ++i) + { + processor.setStepGate (i, false); + processor.setStepNote (i, 0); + processor.setStepCents (i, 0.0f); + processor.setStepSlide (i, false); + } + + refresh(); +} + +void StepPanel::resized() +{ + const int w = getWidth(); + + titleLabel.setBounds (0, 4, w, 16); + noteLabel.setBounds (0, 22, w, 28); + gateLabel.setBounds (0, 50, w, 14); + + const int knobX = (w - 74) / 2; + const int knobY = 68; + + fineKnob.setBounds (knobX, knobY, 74, 74); + + const int btnY = knobY + 25; + prevButton.setBounds (knobX - 32, btnY, 24, 24); + nextButton.setBounds (knobX + 74 + 8, btnY, 24, 24); + + hintLabel.setBounds (8, knobY + 82, w - 16, 14); + clearButton.setBounds (10, knobY + 106, w - 20, 26); +} + +//============================================================================== +MonoStepAudioProcessorEditor::MonoStepAudioProcessorEditor (MonoStepAudioProcessor& processor) + : AudioProcessorEditor (processor), + processorRef (processor), + content (*this), + lnf (std::make_unique()), + lengthKnob ("Length"), + swingKnob ("Swing"), + rootKnob ("Root"), + gateKnob ("Gate"), + masterKnob ("Master"), + detuneKnob ("Detune"), + mixKnob ("Mix"), + cutoffKnob ("Cutoff"), + resKnob ("Res"), + glideKnob ("Glide"), + attackKnob ("Attack"), + decayKnob ("Decay"), + sustainKnob ("Sustain"), + releaseKnob ("Release"), + rateBar (processor.getAPVTS(), "seqRate", { "1/4", "1/8", "1/8T", "1/16", "1/32" }), + octaveBar (processor.getAPVTS(), "seqOctave", { "-2", "-1", "0", "+1", "+2" }), + zoomBar ({ "100%", "125%", "150%", "175%", "200%" }, [] (int) {}), + wave1Bar (processor.getAPVTS(), "osc1Wave", { "Sin", "Tri", "Saw", "Sq" }), + wave2Bar (processor.getAPVTS(), "osc2Wave", { "Sin", "Tri", "Saw", "Sq" }), + matrix (processor), + stepPanel (processor) +{ + setLookAndFeel (lnf.get()); + + setSize (baseWidth, baseHeight); + + auto& apvts = processor.getAPVTS(); + + addAndMakeVisible (content); + + content.addAndMakeVisible (rateBar); + content.addAndMakeVisible (octaveBar); + content.addAndMakeVisible (zoomBar); + content.addAndMakeVisible (wave1Bar); + content.addAndMakeVisible (wave2Bar); + + 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)]); + }); + + auto addKnob = [&apvts, this] (Knob& knob, const juce::String& id, std::function fmt) + { + content.addAndMakeVisible (knob); + knob.attach (apvts, id); + knob.setFormat (std::move (fmt)); + }; + + addKnob (lengthKnob, "seqLen", [] (float v) { return juce::String ((int) v); }); + addKnob (swingKnob, "seqSwing", [] (float v) { return juce::String (v, 2); }); + addKnob (rootKnob, "seqRoot", [] (float v) { return juce::String ((int) v); }); + addKnob (gateKnob, "seqGateLen", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; }); + addKnob (masterKnob, "master", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; }); + addKnob (detuneKnob, "detune", [] (float v) { return juce::String (juce::roundToInt (v)) + " ct"; }); + addKnob (mixKnob, "mix", [] (float v) { return juce::String (v, 2); }); + addKnob (cutoffKnob, "cutoff", [] (float v) { return v >= 1000.0f ? juce::String (juce::roundToInt (v / 100.0f) * 100) + " Hz" : juce::String (juce::roundToInt (v)) + " Hz"; }); + addKnob (resKnob, "res", [] (float v) { return juce::String (v, 2); }); + addKnob (glideKnob, "glide", [] (float v) { return juce::String (v, 2) + "s"; }); + addKnob (attackKnob, "attack", [] (float v) { return juce::String (v, 2) + "s"; }); + 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"; }); + + content.addAndMakeVisible (matrix); + content.addAndMakeVisible (stepPanel); + + matrix.onStepSelected = [this] (int stepIdx) + { + stepPanel.setSelectedStep (stepIdx); + }; + + stepPanel.onStepSelected = [this] (int stepIdx) + { + matrix.setSelectedStep (stepIdx); + }; + + lengthKnob.setOnValueChanged ([this] (float) + { + matrix.repaint(); + }); + + matrix.onGateChanged = [this] (int stepIdx, bool gate) + { + processorRef.setStepGate (stepIdx, gate); + stepPanel.refresh(); + }; + + matrix.onNoteChanged = [this] (int stepIdx, int semitone) + { + processorRef.setStepNote (stepIdx, semitone); + stepPanel.refresh(); + }; + + matrix.onFineChanged = [this] (int stepIdx, float cents) + { + processorRef.setStepCents (stepIdx, cents); + stepPanel.refresh(); + }; + + matrix.onSlideChanged = [this] (int stepIdx, bool slide) + { + processorRef.setStepSlide (stepIdx, slide); + stepPanel.refresh(); + }; + + processorRef.onPatternChanged = [this] { refreshEditor(); }; + + startTimerHz (30); + + refreshEditor(); +} + +MonoStepAudioProcessorEditor::~MonoStepAudioProcessorEditor() +{ + stopTimer(); + processorRef.onPatternChanged = nullptr; + setLookAndFeel (nullptr); +} + +void MonoStepAudioProcessorEditor::timerCallback() +{ + const int step = processorRef.getCurrentStep(); + + if (step != lastPlayStep) + { + lastPlayStep = step; + matrix.setPlayPosition (step); + } +} + +void MonoStepAudioProcessorEditor::refreshEditor() +{ + matrix.setSelectedStep (matrix.getSelectedStep()); + stepPanel.refresh(); + matrix.repaint(); +} + +void MonoStepAudioProcessorEditor::paint (juce::Graphics& g) +{ + g.fillAll (colours::bg); +} + +void MonoStepAudioProcessorEditor::paintContent (juce::Graphics& g) +{ + g.setFont (font (22.0f)); + g.setColour (colours::text); + g.drawText ("MonoStep", 16, 12, 130, 24, juce::Justification::centredLeft); + + g.setFont (font (9.0f)); + g.setColour (colours::textDim); + g.drawText ("2 OSC - step sequencer", 16, 34, 130, 12, juce::Justification::centredLeft); + + const auto drawCaption = [&] (const juce::Component& component, const juce::String& text) + { + g.drawText (text, + component.getX(), component.getY() - 13, + component.getWidth(), 12, + juce::Justification::centred); + }; + + drawCaption (rateBar, "RATE"); + drawCaption (octaveBar, "OCTAVE"); + drawCaption (zoomBar, "ZOOM"); + drawCaption (wave1Bar, "OSC 1"); + drawCaption (wave2Bar, "OSC 2"); + + const int footY = baseHeight - 20; + g.setColour (colours::grid); + g.fillRect (0, footY, baseWidth, 20); + g.setColour (colours::panel); + g.drawHorizontalLine (footY, 0, (float) baseWidth); + + g.setFont (font (9.0f)); + g.setColour (colours::textDim); + g.drawText (buildinfo::footerText(), 16, footY + 4, baseWidth - 32, 12, juce::Justification::centredLeft); +} + +void MonoStepAudioProcessorEditor::setZoomFactor (float newZoom) +{ + zoomFactor = newZoom; + + setSize (juce::roundToInt (baseWidth * newZoom), juce::roundToInt (baseHeight * newZoom)); +} + +void MonoStepAudioProcessorEditor::resized() +{ + const int targetW = juce::roundToInt (baseWidth * zoomFactor); + const int targetH = juce::roundToInt (baseHeight * zoomFactor); + + if (getWidth() != targetW || getHeight() != targetH) + { + setSize (targetW, targetH); + return; + } + + content.setBounds (0, 0, baseWidth, baseHeight); + content.setTransform (juce::AffineTransform::scale (zoomFactor)); + + // All layout below happens in design units; the content transform scales it. + const int w = baseWidth; + const int h = baseHeight; + + const int topBarH = 58; + const int bottomBarH = 96; + const int pad = 12; + + lengthKnob.setBounds (128, 2, 54, 56); + rateBar.setBounds (192, 18, 210, 26); + + swingKnob.setBounds (422, 2, 54, 56); + rootKnob.setBounds (482, 2, 54, 56); + octaveBar.setBounds (548, 18, 170, 26); + zoomBar.setBounds (w - pad - 54 - 8 - 236, 18, 236, 26); + masterKnob.setBounds (w - pad - 54, 2, 54, 56); + + const int panelW = 200; + const int midTop = topBarH; + const int midH = h - topBarH - bottomBarH - pad; + + stepPanel.setBounds (w - pad - panelW, midTop, panelW, midH); + matrix.setBounds (pad, midTop, w - pad * 2 - panelW - pad, midH); + + const int bottomY = h - bottomBarH - pad; + + int x = 16; + const int barW = 112; + + wave1Bar.setBounds (x, bottomY + 30, barW, 24); x += barW + 8; + wave2Bar.setBounds (x, bottomY + 30, barW, 24); x += barW + 16; + + const int knobW = 56; + const int knobGap = 72; + const int knobY = bottomY + 2; + + const auto placeKnob = [&] (Knob& knob) + { + knob.setBounds (x, knobY, knobW, 82); + x += knobGap; + }; + + placeKnob (detuneKnob); + placeKnob (mixKnob); + placeKnob (cutoffKnob); + placeKnob (resKnob); + placeKnob (glideKnob); + placeKnob (attackKnob); + placeKnob (decayKnob); + placeKnob (sustainKnob); + placeKnob (releaseKnob); +} diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..ce7ec01 --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,190 @@ +#pragma once + +#include +#include "ui/SequencerMatrix.h" + +class MonoStepAudioProcessor; + +class EditorLookAndFeel final : public juce::LookAndFeel_V4 +{ +public: + EditorLookAndFeel(); + + void drawRotarySlider (juce::Graphics&, int x, int y, int w, int h, + float sliderPos, float rotaryStartAngle, float rotaryEndAngle, + juce::Slider&) override; + + void drawToggleButton (juce::Graphics&, juce::ToggleButton&, + bool shouldDrawButtonAsHighlighted, bool shouldDrawButtonAsDown) override; + + void drawButtonBackground (juce::Graphics&, juce::Button&, + const juce::Colour& backgroundColour, bool shouldDrawButtonAsHighlighted, + bool shouldDrawButtonAsDown) override; + + void drawButtonText (juce::Graphics&, juce::TextButton&, + bool shouldDrawButtonAsHighlighted, bool shouldDrawButtonAsDown) override; +}; + +class Knob final : public juce::Component +{ +public: + explicit Knob (const juce::String& title); + + juce::Slider& getSlider() { return slider; } + + void attach (juce::AudioProcessorValueTreeState& apvts, const juce::String& paramId) + { + attachment = std::make_unique (apvts, paramId, slider); + } + + void setFormat (std::function formatFn) + { + format = std::move (formatFn); + updateValueLabel(); + } + + void setOnValueChanged (std::function valueChangedFn) + { + customOnValueChange = std::move (valueChangedFn); + } + + void resized() override; + +private: + void updateValueLabel(); + + juce::Slider slider; + juce::Label titleLabel; + juce::Label valueLabel; + std::unique_ptr attachment; + std::function format; + std::function customOnValueChange; +}; + +class ChoiceBar final : public juce::Component, + public juce::AudioProcessorValueTreeState::Listener +{ +public: + ChoiceBar (juce::AudioProcessorValueTreeState& apvts, + const juce::String& paramId, + const juce::StringArray& choices); + + ChoiceBar (const juce::StringArray& choices, + std::function onSelected); + + ~ChoiceBar() override; + + void parameterChanged (const juce::String&, float) override; + + void setManualHandler (std::function handler) { manualHandler = std::move (handler); } + + void resized() override; + +private: + void refreshFromParam(); + void setIndex (int index); + + juce::AudioProcessorValueTreeState* apvts = nullptr; + juce::String paramId; + std::function manualHandler; + juce::OwnedArray buttons; +}; + +class StepPanel final : public juce::Component +{ +public: + explicit StepPanel (MonoStepAudioProcessor& processor); + + void setSelectedStep (int stepIdx); + void refresh(); + + std::function onStepSelected; + + void resized() override; + +private: + void clearPattern(); + + MonoStepAudioProcessor& processor; + + int selectedStep = 0; + bool updating = false; + + juce::Label titleLabel; + juce::Label noteLabel; + juce::Label gateLabel; + Knob fineKnob; + juce::TextButton prevButton; + juce::TextButton nextButton; + juce::Label hintLabel; + juce::TextButton clearButton; +}; + +class MonoStepAudioProcessorEditor final : public juce::AudioProcessorEditor, + public juce::Timer +{ +public: + explicit MonoStepAudioProcessorEditor (MonoStepAudioProcessor& processor); + ~MonoStepAudioProcessorEditor() override; + + void paint (juce::Graphics&) override; + void resized() override; + + void timerCallback() override; + +private: + void paintContent (juce::Graphics&); + + class ContentComponent final : public juce::Component + { + public: + explicit ContentComponent (MonoStepAudioProcessorEditor& owner) : ownerRef (owner) {} + + void paint (juce::Graphics& g) override { ownerRef.paintContent (g); } + + private: + MonoStepAudioProcessorEditor& ownerRef; + }; + + MonoStepAudioProcessor& processorRef; + + ContentComponent content; + + static constexpr int baseWidth = 1060; + static constexpr int baseHeight = 640; + + float zoomFactor = 1.0f; + + void setZoomFactor (float newZoom); + + std::unique_ptr lnf; + std::vector> attachments; + + Knob lengthKnob; + Knob swingKnob; + Knob rootKnob; + Knob gateKnob; + Knob masterKnob; + Knob detuneKnob; + Knob mixKnob; + Knob cutoffKnob; + Knob resKnob; + Knob glideKnob; + Knob attackKnob; + Knob decayKnob; + Knob sustainKnob; + Knob releaseKnob; + + ChoiceBar rateBar; + ChoiceBar octaveBar; + ChoiceBar zoomBar; + ChoiceBar wave1Bar; + ChoiceBar wave2Bar; + + SequencerMatrix matrix; + StepPanel stepPanel; + + int lastPlayStep = -1; + + void refreshEditor(); +}; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..ed296fb --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -0,0 +1,516 @@ +#include "PluginProcessor.h" + +#ifndef MONOSTEP_HEADLESS +#include "PluginEditor.h" +#endif + +static const char* paramId (int index) +{ + static const char* ids[] = + { + "osc1Wave", "osc2Wave", "detune", "mix", + "cutoff", "res", "attack", "decay", "sustain", "release", "glide", "master", + "seqLen", "seqRate", "seqRoot", "seqOctave", "seqSwing", "seqGateLen" + }; + return ids[index]; +} + +enum ParamIndex +{ + pOsc1Wave, pOsc2Wave, pDetune, pMix, + pCutoff, pRes, pAttack, pDecay, pSustain, pRelease, pGlide, pMaster, + pSeqLen, pSeqRate, pSeqRoot, pSeqOctave, pSeqSwing, pSeqGateLen, + numParams +}; + +MonoStepAudioProcessor::MonoStepAudioProcessor() + : AudioProcessor (BusesProperties() + .withOutput ("Output", juce::AudioChannelSet::stereo(), true)) + , apvts (*this, nullptr, "PARAMS", createParameterLayout()) +{ + osc1WaveParam = apvts.getRawParameterValue (paramId (pOsc1Wave)); + osc2WaveParam = apvts.getRawParameterValue (paramId (pOsc2Wave)); + detuneParam = apvts.getRawParameterValue (paramId (pDetune)); + mixParam = apvts.getRawParameterValue (paramId (pMix)); + cutoffParam = apvts.getRawParameterValue (paramId (pCutoff)); + resParam = apvts.getRawParameterValue (paramId (pRes)); + attackParam = apvts.getRawParameterValue (paramId (pAttack)); + decayParam = apvts.getRawParameterValue (paramId (pDecay)); + sustainParam = apvts.getRawParameterValue (paramId (pSustain)); + releaseParam = apvts.getRawParameterValue (paramId (pRelease)); + glideParam = apvts.getRawParameterValue (paramId (pGlide)); + masterParam = apvts.getRawParameterValue (paramId (pMaster)); + seqLenParam = apvts.getRawParameterValue (paramId (pSeqLen)); + seqRateParam = apvts.getRawParameterValue (paramId (pSeqRate)); + seqRootParam = apvts.getRawParameterValue (paramId (pSeqRoot)); + seqOctaveParam = apvts.getRawParameterValue (paramId (pSeqOctave)); + seqSwingParam = apvts.getRawParameterValue (paramId (pSeqSwing)); + seqGateLenParam = apvts.getRawParameterValue (paramId (pSeqGateLen)); + + setDefaultPattern(); +} + +juce::AudioProcessorValueTreeState::ParameterLayout MonoStepAudioProcessor::createParameterLayout() +{ + juce::AudioProcessorValueTreeState::ParameterLayout layout; + + layout.add (std::make_unique (paramId (pOsc1Wave), "Osc 1 Wave", 0, 3, 2)); + layout.add (std::make_unique (paramId (pOsc2Wave), "Osc 2 Wave", 0, 3, 3)); + + layout.add (std::make_unique (paramId (pDetune), + "Osc 2 Detune", juce::NormalisableRange (-100.0f, 100.0f), 0.0f, + juce::AudioParameterFloatAttributes().withLabel ("ct"))); + + layout.add (std::make_unique (paramId (pMix), + "Osc Mix", juce::NormalisableRange (0.0f, 1.0f), 0.5f)); + + layout.add (std::make_unique (paramId (pCutoff), + "Filter Cutoff", juce::NormalisableRange (40.0f, 16000.0f, 0.01f, 0.3f), 7000.0f, + juce::AudioParameterFloatAttributes().withLabel ("Hz"))); + + layout.add (std::make_unique (paramId (pRes), + "Filter Res", juce::NormalisableRange (0.0f, 1.0f), 0.15f)); + + layout.add (std::make_unique (paramId (pAttack), + "Attack", juce::NormalisableRange (0.001f, 2.0f, 0.001f, 0.3f), 0.005f, + juce::AudioParameterFloatAttributes().withLabel ("s"))); + + layout.add (std::make_unique (paramId (pDecay), + "Decay", juce::NormalisableRange (0.001f, 3.0f, 0.001f, 0.3f), 0.3f, + juce::AudioParameterFloatAttributes().withLabel ("s"))); + + layout.add (std::make_unique (paramId (pSustain), + "Sustain", juce::NormalisableRange (0.0f, 1.0f), 0.7f)); + + layout.add (std::make_unique (paramId (pRelease), + "Release", juce::NormalisableRange (0.01f, 4.0f, 0.001f, 0.3f), 0.4f, + juce::AudioParameterFloatAttributes().withLabel ("s"))); + + layout.add (std::make_unique (paramId (pGlide), + "Glide", juce::NormalisableRange (0.0f, 1.0f, 0.001f, 0.5f), 0.12f, + juce::AudioParameterFloatAttributes().withLabel ("s"))); + + layout.add (std::make_unique (paramId (pMaster), + "Master", juce::NormalisableRange (0.0f, 1.0f), 0.9f)); + + layout.add (std::make_unique (paramId (pSeqLen), "Pattern Length", 1, 16, 16)); + + juce::StringArray rates { "1/4", "1/8", "1/8T", "1/16", "1/32" }; + layout.add (std::make_unique (paramId (pSeqRate), "Rate", rates, 3)); + + layout.add (std::make_unique (paramId (pSeqRoot), "Root", -24, 24, 0)); + + juce::StringArray octaves { "-2", "-1", "0", "+1", "+2" }; + layout.add (std::make_unique (paramId (pSeqOctave), "Octave", octaves, 3)); + + layout.add (std::make_unique (paramId (pSeqSwing), + "Swing", juce::NormalisableRange (0.0f, 0.6f), 0.0f)); + + layout.add (std::make_unique (paramId (pSeqGateLen), + "Gate Length", juce::NormalisableRange (0.05f, 1.0f), 0.8f)); + + return layout; +} + +void MonoStepAudioProcessor::setDefaultPattern() +{ + struct Def { int step; bool gate; int semitone; bool slide; }; + const Def defs[] = + { + { 0, true, 0, false }, + { 1, true, 0, true }, + { 2, true, 3, false }, + { 4, true, 5, false }, + { 5, true, 3, true }, + { 6, true, 0, false }, + { 8, true, 7, false }, + { 9, true, 5, true }, + { 10, true, 3, false }, + { 12, true, 0, false }, + { 14, true, -2, true }, + }; + + for (const auto& d : defs) + { + sequencer.step (d.step).gate = d.gate; + sequencer.step (d.step).semitone = d.semitone; + sequencer.step (d.step).slide = d.slide; + } +} + +void MonoStepAudioProcessor::prepareToPlay (double sr, int samplesPerBlock) +{ + sampleRate = sr; + voice.prepare (sr); + scratch.setSize (1, samplesPerBlock); + lastStep = -1; + lastGateApplied = false; + lastFreqApplied = -1.0f; + freePpq = 0.0; + wasPlaying = false; + lastHostPpq = -1.0; +} + +void MonoStepAudioProcessor::releaseResources() +{ + voice.reset(); +} + +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, detuneParam->load() / 1200.0f); + p.mix = mixParam->load(); + p.cutoff = cutoffParam->load(); + p.resonance = resParam->load(); + p.attack = attackParam->load(); + p.decay = decayParam->load(); + p.sustain = sustainParam->load(); + p.release = releaseParam->load(); + p.glide = glideParam->load(); + p.master = masterParam->load(); + voice.setParams (p); +} + +float MonoStepAudioProcessor::midiToFreq (float note) const +{ + return 440.0f * std::pow (2.0f, (note - 69.0f) / 12.0f); +} + +void MonoStepAudioProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages) +{ + juce::ScopedNoDenormals noDenormals; + + const int numSamples = buffer.getNumSamples(); + const int numChannels = buffer.getNumChannels(); + + buffer.clear(); + + if (numSamples == 0) + return; + + updateVoiceParams(); + + const int rateIdx = juce::roundToInt (seqRateParam->load()); + const int root = juce::roundToInt (seqRootParam->load()); + const int octave = juce::roundToInt (seqOctaveParam->load()) - 2; + const float swing = seqSwingParam->load(); + const float stepLen = sequencer.stepLenPpq (rateIdx); + const int seqLen = juce::roundToInt (seqLenParam->load()); + const float cyclePpq = (float) seqLen * stepLen; + + // --- MIDI notes (gate/trigger for the sequencer) ------------------------- + for (const auto metadata : midiMessages) + { + const auto msg = metadata.getMessage(); + + if (msg.isNoteOn()) + { + midiHeld = true; + lastMidiNote = msg.getNoteNumber(); + } + else if (msg.isNoteOff()) + { + if (msg.getNoteNumber() == lastMidiNote || ! midiHeld) + midiHeld = false; + } + } + + // --- transport / clock -------------------------------------------------- + bool hostPlaying = false; + double hostPpq = 0.0; + double bpm = 120.0; + + if (auto* playHead = getPlayHead()) + if (auto pos = playHead->getPosition()) + { + hostPlaying = pos->getIsPlaying(); + + if (auto ppq = pos->getPpqPosition()) + hostPpq = *ppq; + + if (auto tempo = pos->getBpm()) + bpm = *tempo; + } + + const double ppqPerSample = (bpm / 60.0) / sampleRate; + + // The sequencer only runs while a MIDI note is held. When the host + // transport is running we follow it, otherwise we free-run at the + // fallback BPM so the pattern still steps when there is no DAW clock. + if (midiHeld) + { + if (hostPlaying) + { + if (lastHostPpq >= 0.0 && hostPpq < lastHostPpq - 1.0) + { + lastStep = -1; + lastGateApplied = false; + } + + freePpq = hostPpq; + } + else + { + if (wasPlaying) + { + lastStep = -1; + lastGateApplied = false; + } + + freePpq += ppqPerSample * numSamples; + } + } + else + { + lastStep = -1; + lastGateApplied = false; + playStep.store (-1); + } + + wasPlaying = hostPlaying; + lastHostPpq = hostPpq; + + // --- sequencer step ----------------------------------------------------- + bool seqGate = false; + float seqFreq = 0.0f; + bool seqSlide = false; + + if (midiHeld) + { + const float gateLen = seqGateLenParam->load(); + + float ppqInCycle = (float) std::fmod (freePpq, (double) cyclePpq); + if (ppqInCycle < 0.0f) + ppqInCycle += cyclePpq; + + int stepIdx = 0; + float stepStart = 0.0f; + + for (int i = 0; i < seqLen; ++i) + { + const float start = i * stepLen + ((i % 2) ? swing * stepLen : 0.0f); + const float nextStart = (i + 1) * stepLen + (((i + 1) % 2) ? swing * stepLen : 0.0f); + + if (ppqInCycle >= start && ppqInCycle < nextStart) + { + stepIdx = i; + stepStart = start; + break; + } + } + + lastStep = stepIdx; + playStep.store (stepIdx); + + const auto& s = sequencer.step (stepIdx); + seqSlide = s.slide; + + // gate length: the step's note sustains for gateLen of the step + const float ppqWithinStep = ppqInCycle - stepStart; + seqGate = s.gate && (ppqWithinStep < stepLen * gateLen); + + if (seqGate) + { + // the held MIDI note transposes the pattern; root/octave shift it further + const float note = (float) lastMidiNote + (float) root + octave * 12.0f + s.semitone; + seqFreq = midiToFreq (note + s.cents / 100.0f); + } + } + + // --- decide voice event --------------------------------------------------- + bool wantGate = false; + float wantFreq = 0.0f; + bool wantLegato = false; + + if (seqGate) + { + wantGate = true; + wantFreq = seqFreq; + wantLegato = seqSlide; + } + + if (wantGate != lastGateApplied) + { + if (wantGate) + voice.noteOn (wantFreq, wantLegato); + else + voice.noteOff(); + + lastGateApplied = wantGate; + lastFreqApplied = wantGate ? wantFreq : -1.0f; + } + else if (wantGate && std::fabs (wantFreq - lastFreqApplied) > 0.01f) + { + voice.noteOn (wantFreq, wantLegato); + lastFreqApplied = wantFreq; + } + + (void) lastStep; + + // --- render --------------------------------------------------------------- + voice.render (scratch, numSamples); + + for (int ch = 0; ch < numChannels; ++ch) + buffer.addFrom (ch, 0, scratch, 0, 0, numSamples); +} + +void MonoStepAudioProcessor::setStepGate (int idx, bool gate) +{ + if (idx < 0 || idx >= monostep::numSteps) + return; + + sequencer.step (idx).gate = gate; + + if (onPatternChanged) + onPatternChanged(); +} + +void MonoStepAudioProcessor::setStepNote (int idx, int semitone) +{ + if (idx < 0 || idx >= monostep::numSteps) + return; + + sequencer.step (idx).semitone = juce::jlimit (monostep::minSemitone, monostep::maxSemitone, semitone); + + if (onPatternChanged) + onPatternChanged(); +} + +void MonoStepAudioProcessor::setStepCents (int idx, float cents) +{ + if (idx < 0 || idx >= monostep::numSteps) + return; + + sequencer.step (idx).cents = juce::jlimit (-50.0f, 50.0f, cents); + + if (onPatternChanged) + onPatternChanged(); +} + +void MonoStepAudioProcessor::setStepSlide (int idx, bool slide) +{ + if (idx < 0 || idx >= monostep::numSteps) + return; + + sequencer.step (idx).slide = slide; + + if (onPatternChanged) + onPatternChanged(); +} + +juce::String MonoStepAudioProcessor::getStepNoteName (int idx) const +{ + const int root = juce::roundToInt (seqRootParam->load()); + const int octave = juce::roundToInt (seqOctaveParam->load()) - 2; + + int note = root + octave * 12 + sequencer.step (idx).semitone; + + if (lastMidiNote >= 0) + note += lastMidiNote; + + return juce::MidiMessage::getMidiNoteName (note, true, true, 3); +} + +void MonoStepAudioProcessor::storeSequence (juce::ValueTree& seq) const +{ + juce::String gates; + juce::String notes; + juce::String cents; + juce::String slides; + + for (int i = 0; i < monostep::numSteps; ++i) + { + const auto& s = sequencer.step (i); + + gates += s.gate ? "1" : "0"; + notes += juce::String (s.semitone) + ","; + cents += juce::String (s.cents, 2) + ","; + slides += s.slide ? "1" : "0"; + } + + seq.setProperty ("gates", gates, nullptr); + seq.setProperty ("notes", notes.dropLastCharacters (1), nullptr); + seq.setProperty ("cents", cents.dropLastCharacters (1), nullptr); + seq.setProperty ("slides", slides, nullptr); +} + +void MonoStepAudioProcessor::loadSequence (const juce::ValueTree& seq) +{ + const auto splitFloats = [] (const juce::String& text) + { + juce::StringArray tokens; + tokens.addTokens (text, ",", ""); + + juce::Array result; + for (const auto& t : tokens) + result.add (t.getFloatValue()); + + return result; + }; + + const auto gates = seq.getProperty ("gates").toString(); + const auto notes = splitFloats (seq.getProperty ("notes").toString()); + const auto cents = splitFloats (seq.getProperty ("cents").toString()); + const auto slides = seq.getProperty ("slides").toString(); + + for (int i = 0; i < monostep::numSteps; ++i) + { + auto& s = sequencer.step (i); + + if (i < gates.length()) + s.gate = gates[i] == '1'; + + if (i < notes.size()) + s.semitone = juce::roundToInt (notes[i]); + + if (i < cents.size()) + s.cents = cents[i]; + + if (i < slides.length()) + s.slide = slides[i] == '1'; + } +} + +void MonoStepAudioProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + juce::ValueTree state = apvts.state.createCopy(); + juce::ValueTree seq = state.getOrCreateChildWithName ("SEQ", nullptr); + storeSequence (seq); + + std::unique_ptr xml (state.createXml()); + copyXmlToBinary (*xml, destData); +} + +void MonoStepAudioProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + std::unique_ptr xml (juce::AudioProcessor::getXmlFromBinary (data, sizeInBytes)); + + if (xml == nullptr) + return; + + const juce::ValueTree state = juce::ValueTree::fromXml (*xml); + apvts.replaceState (state); + + if (auto seq = state.getChildWithName ("SEQ"); seq.isValid()) + loadSequence (seq); + + if (onPatternChanged) + onPatternChanged(); +} + +juce::AudioProcessorEditor* MonoStepAudioProcessor::createEditor() +{ +#ifndef MONOSTEP_HEADLESS + return new MonoStepAudioProcessorEditor (*this); +#else + return nullptr; +#endif +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new MonoStepAudioProcessor(); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..dd38f47 --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include "dsp/SynthVoice.h" +#include "dsp/StepSequencer.h" + +class MonoStepAudioProcessor final : public juce::AudioProcessor +{ +public: + MonoStepAudioProcessor(); + ~MonoStepAudioProcessor() override = default; + + void prepareToPlay (double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override { return true; } + + const juce::String getName() const override { return "MonoStep"; } + bool acceptsMidi() const override { return true; } + bool producesMidi() const override { return false; } + double getTailLengthSeconds() const override { return 0.0; } + + int getNumPrograms() override { return 1; } + int getCurrentProgram() override { return 0; } + void setCurrentProgram (int) override {} + const juce::String getProgramName (int) override { return {}; } + void changeProgramName (int, const juce::String&) override {} + + void getStateInformation (juce::MemoryBlock&) override; + void setStateInformation (const void*, int) override; + + juce::AudioProcessorValueTreeState& getAPVTS() { return apvts; } + monostep::StepSequencer& getSequencer() { return sequencer; } + const monostep::StepSequencer& getSequencer() const { return sequencer; } + + int getNumSteps() const { return monostep::numSteps; } + int getPatternLength() const { return juce::roundToInt (seqLenParam->load()); } + int getCurrentStep() const { return playStep.load(); } + + void setStepGate (int idx, bool gate); + void setStepNote (int idx, int semitone); + void setStepCents (int idx, float cents); + void setStepSlide (int idx, bool slide); + + juce::String getStepNoteName (int idx) const; + + std::function onPatternChanged; + +private: + static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout(); + + void updateVoiceParams(); + float midiToFreq (float note) const; + void storeSequence (juce::ValueTree& seq) const; + void loadSequence (const juce::ValueTree& seq); + void setDefaultPattern(); + + juce::AudioProcessorValueTreeState apvts; + + std::atomic* osc1WaveParam = nullptr; + std::atomic* osc2WaveParam = nullptr; + std::atomic* detuneParam = nullptr; + std::atomic* mixParam = nullptr; + std::atomic* cutoffParam = nullptr; + std::atomic* resParam = nullptr; + std::atomic* attackParam = nullptr; + std::atomic* decayParam = nullptr; + std::atomic* sustainParam = nullptr; + std::atomic* releaseParam = nullptr; + std::atomic* glideParam = nullptr; + std::atomic* masterParam = nullptr; + std::atomic* seqLenParam = nullptr; + std::atomic* seqRateParam = nullptr; + std::atomic* seqRootParam = nullptr; + std::atomic* seqOctaveParam = nullptr; + std::atomic* seqSwingParam = nullptr; + std::atomic* seqGateLenParam = nullptr; + + monostep::StepSequencer sequencer; + monostep::SynthVoice voice; + juce::AudioBuffer scratch; + + double sampleRate = 44100.0; + + int lastStep = -1; + std::atomic playStep { -1 }; + bool lastGateApplied = false; + float lastFreqApplied = -1.0f; + + bool midiHeld = false; + int lastMidiNote = -1; + + bool wasPlaying = false; + double lastHostPpq = -1.0; + double freePpq = 0.0; +}; diff --git a/Source/Theme.h b/Source/Theme.h new file mode 100644 index 0000000..62cb3e2 --- /dev/null +++ b/Source/Theme.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +namespace monostep +{ + +namespace colours +{ + inline const juce::Colour bg (0xFF131519); + inline const juce::Colour panel (0xFF1C1F25); + inline const juce::Colour grid (0xFF262B33); + inline const juce::Colour gridLight (0xFF2E343E); + inline const juce::Colour text (0xFFC9CDD4); + inline const juce::Colour textDim (0xFF6E7683); + inline const juce::Colour accent (0xFFE8A33D); + inline const juce::Colour accent2 (0xFF7FA6FF); + inline const juce::Colour accentDim (0xFF7A5A26); + inline const juce::Colour activeCell (0xFF3A6EA5); + inline const juce::Colour selected (0xFFF0F4F8); + inline const juce::Colour playMarker (0xFFE5484D); +} + +inline const juce::Font font (float height) +{ + return juce::Font (juce::FontOptions (height)); +} + +inline juce::String noteNameForOffset (int semitone) +{ + const int note = 48 + semitone; + return juce::MidiMessage::getMidiNoteName (note, true, true, 3); +} + +inline juce::String formatCents (float cents) +{ + if (std::fabs (cents) < 0.5f) + return "0 ct"; + + return juce::String (cents, 0) + " ct"; +} + +} // namespace monostep diff --git a/Source/dsp/StepSequencer.h b/Source/dsp/StepSequencer.h new file mode 100644 index 0000000..7a63ffd --- /dev/null +++ b/Source/dsp/StepSequencer.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +namespace monostep +{ + +static constexpr int numSteps = 16; +static constexpr int minSemitone = -12; +static constexpr int maxSemitone = 12; +static constexpr int numRows = maxSemitone - minSemitone + 1; + +struct Step +{ + bool gate = false; + int semitone = 0; // -12 .. +12 + float cents = 0.0f; // -50 .. +50 + bool slide = false; +}; + +class StepSequencer +{ +public: + Step& step (int i) { return steps[i]; } + const Step& step (int i) const { return steps[i]; } + + void clear() + { + for (auto& s : steps) + s = Step{}; + } + + int stepsPerBeatFromRate (int rateIndex) const + { + // 0: 1/4, 1: 1/8, 2: 1/8T, 3: 1/16, 4: 1/32 + switch (rateIndex) + { + case 0: return 1; + case 1: return 2; + case 2: return 3; + case 4: return 8; + default: return 4; + } + } + + float stepLenPpq (int rateIndex) const + { + return 1.0f / (float) stepsPerBeatFromRate (rateIndex); + } + +private: + std::array steps; +}; + +} // namespace monostep diff --git a/Source/dsp/SynthVoice.h b/Source/dsp/SynthVoice.h new file mode 100644 index 0000000..85339bc --- /dev/null +++ b/Source/dsp/SynthVoice.h @@ -0,0 +1,189 @@ +#pragma once + +#include +#include "WaveTables.h" + +namespace monostep +{ + +class SynthVoice +{ +public: + struct Params + { + Waveform waveA = Waveform::saw; + Waveform waveB = Waveform::square; + float detuneRatio = 1.0f; + float mix = 0.5f; + float cutoff = 7000.0f; + float resonance = 0.15f; + float attack = 0.005f; + float decay = 0.3f; + float sustain = 0.7f; + float release = 0.4f; + float glide = 0.12f; + float master = 0.9f; + }; + + void prepare (double sr) + { + sampleRate = (float) sr; + reset(); + } + + void reset() + { + phaseA = phaseB = 0.0f; + currentFreq = targetFreq = 440.0f; + smoothing = 0.0f; + filterLow = filterBand = 0.0f; + env = 0.0f; + envStage = Stage::idle; + gate = false; + } + + void setParams (const Params& p) { params = p; } + + bool isActive() const { return gate; } + + void noteOn (float freqHz, bool legato) + { + targetFreq = freqHz; + + if (gate && legato) + { + const float time = std::max (params.glide, 0.001f); + smoothing = 1.0f - std::exp (-1.0f / (time * sampleRate)); + } + else + { + currentFreq = targetFreq; + smoothing = 0.0f; + retriggerEnvelope(); + } + + gate = true; + } + + void noteOff() + { + if (gate) + { + gate = false; + + if (envStage != Stage::idle && envStage != Stage::release) + envStage = Stage::release; + } + } + + void render (juce::AudioBuffer& buffer, int numSamples) + { + auto* out = buffer.getWritePointer (0); + + for (int i = 0; i < numSamples; ++i) + out[i] = renderSample(); + } + +private: + enum class Stage { idle, attack, decay, sustain, release }; + + void retriggerEnvelope() + { + envStage = Stage::attack; + } + + void updateEnvelope() + { + const float sr = sampleRate; + + switch (envStage) + { + case Stage::attack: + env += 1.0f / (params.attack * sr); + if (env >= 1.0f) { env = 1.0f; envStage = Stage::decay; } + break; + + case Stage::decay: + env -= (1.0f - params.sustain) / (params.decay * sr); + if (env <= params.sustain) { env = params.sustain; envStage = Stage::sustain; } + break; + + case Stage::sustain: + env = params.sustain; + break; + + case Stage::release: + env -= 1.0f / (params.release * sr); + if (env <= 0.0f) { env = 0.0f; envStage = Stage::idle; } + break; + + case Stage::idle: + break; + } + } + + void updateGlide() + { + if (smoothing > 0.0f) + { + currentFreq += (targetFreq - currentFreq) * smoothing; + + if (std::fabs (targetFreq - currentFreq) < 0.01f) + { + currentFreq = targetFreq; + smoothing = 0.0f; + } + } + } + + float processFilter (float input) + { + const float f = 2.0f * std::sin (juce::MathConstants::pi * params.cutoff / sampleRate); + const float q = 1.0f / (1.0f + params.resonance * 9.0f); + + filterLow += f * filterBand; + const float high = input - filterLow - q * filterBand; + filterBand += f * high; + + return filterLow; + } + + float renderSample() + { + updateEnvelope(); + updateGlide(); + + const float incA = currentFreq / sampleRate; + const float incB = (currentFreq * params.detuneRatio) / sampleRate; + + const float a = renderWave (params.waveA, phaseA, incA); + const float b = renderWave (params.waveB, phaseB, incB); + + phaseA = frac (phaseA + incA); + phaseB = frac (phaseB + incB); + + float out = (1.0f - params.mix) * a + params.mix * b; + + out = processFilter (out); + + return out * env * params.master; + } + + float sampleRate = 44100.0f; + Params params; + + float phaseA = 0.0f; + float phaseB = 0.0f; + float currentFreq = 440.0f; + float targetFreq = 440.0f; + float smoothing = 0.0f; + + float filterLow = 0.0f; + float filterBand = 0.0f; + + float env = 0.0f; + Stage envStage = Stage::idle; + bool gate = false; +}; + +} // namespace monostep diff --git a/Source/dsp/WaveTables.h b/Source/dsp/WaveTables.h new file mode 100644 index 0000000..f51fef2 --- /dev/null +++ b/Source/dsp/WaveTables.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include + +namespace monostep +{ + +enum class Waveform : int +{ + sine = 0, + triangle = 1, + saw = 2, + square = 3 +}; + +inline float frac (float x) +{ + return x - std::floor (x); +} + +inline float polyBlep (float t, float dt) +{ + if (t < dt) + { + t /= dt; + return t + t - t * t - 1.0f; + } + + if (t > 1.0f - dt) + { + t = (t - 1.0f) / dt; + return t * t + t + t + 1.0f; + } + + return 0.0f; +} + +inline float renderWave (Waveform w, float phase, float inc) +{ + switch (w) + { + case Waveform::sine: + return std::sin (phase * 6.283185307179586f); + + case Waveform::triangle: + { + const float f = frac (phase); + return 4.0f * std::min (f, 1.0f - f) - 1.0f; + } + + case Waveform::saw: + { + const float f = frac (phase); + return (2.0f * f - 1.0f) - polyBlep (f, inc); + } + + case Waveform::square: + { + const float f = frac (phase); + float v = (f < 0.5f) ? 1.0f : -1.0f; + v += polyBlep (f, inc); + v -= polyBlep (frac (f + 0.5f), inc); + return v; + } + } + + return 0.0f; +} + +} // namespace monostep diff --git a/Source/ui/SequencerMatrix.cpp b/Source/ui/SequencerMatrix.cpp new file mode 100644 index 0000000..2b7386a --- /dev/null +++ b/Source/ui/SequencerMatrix.cpp @@ -0,0 +1,412 @@ +#include "SequencerMatrix.h" + +#include "../PluginProcessor.h" +#include "../Theme.h" + +using namespace monostep; + +SequencerMatrix::SequencerMatrix (MonoStepAudioProcessor& processorRef) + : processor (processorRef) +{ +} + +int SequencerMatrix::rowForSemitone (int semitone) const +{ + return maxSemitone - semitone; +} + +int SequencerMatrix::semitoneForRow (int row) const +{ + return maxSemitone - row; +} + +juce::Rectangle SequencerMatrix::matrixBounds() const +{ + const int gridW = juce::roundToInt (cellW * numSteps); + const int gridH = juce::roundToInt (cellH * (numRows + 1)); + + const int x = juce::roundToInt (labelW + (getWidth() - labelW - gridW) / 2.0f); + const int y = juce::roundToInt (headerH + (getHeight() - headerH - gridH) / 2.0f); + + return { x, y, gridW, gridH }; +} + +void SequencerMatrix::setSelectedStep (int stepIdx) +{ + if (stepIdx < 0 || stepIdx >= numSteps) + return; + + selectedStep = stepIdx; + repaint(); +} + +void SequencerMatrix::setPlayPosition (int stepIdx) +{ + if (stepIdx < -1 || stepIdx >= numSteps) + return; + + if (stepIdx == playPos) + return; + + playPos = stepIdx; + repaint(); +} + +void SequencerMatrix::paint (juce::Graphics& g) +{ + g.fillAll (colours::panel); + + const auto bounds = matrixBounds(); + const float ox = (float) bounds.getX(); + const float oy = (float) bounds.getY(); + + // grid background + g.setColour (colours::bg); + g.fillRect (bounds.toFloat()); + + // selected column highlight + g.setColour (colours::selected.withAlpha (0.07f)); + g.fillRect (ox + selectedStep * cellW, oy, cellW, (float) bounds.getHeight()); + + // grid lines + g.setColour (colours::grid); + + for (int i = 0; i <= numSteps; ++i) + { + const float x = ox + i * cellW; + g.drawVerticalLine (juce::roundToInt (x), oy, oy + (float) bounds.getHeight()); + } + + for (int i = 0; i <= numRows + 1; ++i) + { + const float y = oy + i * cellH; + g.drawHorizontalLine (juce::roundToInt (y), ox, ox + (float) bounds.getWidth()); + } + + // accent C rows (semitone multiple of 12) + for (int r = 0; r < numRows; ++r) + { + const int st = semitoneForRow (r); + + if (st % 12 == 0) + { + g.setColour (colours::gridLight); + const float y = oy + r * cellH; + g.drawHorizontalLine (juce::roundToInt (y), ox, ox + (float) bounds.getWidth()); + } + } + + // step header numbers + selected marker + const int seqLen = processor.getPatternLength(); + + for (int i = 0; i < numSteps; ++i) + { + const float cx = ox + i * cellW + cellW * 0.5f; + const float cy = oy - headerH * 0.5f; + + g.setColour ((i % 4 == 0 && i < seqLen) ? colours::text : colours::textDim); + g.setFont (font (10.0f)); + g.drawText (juce::String (i + 1), juce::roundToInt (cx - 8.0f), juce::roundToInt (cy - 6.0f), 16, 12, juce::Justification::centred); + + if (i == selectedStep) + { + g.setColour (colours::accent); + juce::Path tri; + tri.addTriangle (cx - 4.0f, cy - 8.0f, cx + 4.0f, cy - 8.0f, cx, cy); + g.fillPath (tri); + } + } + + // pitch labels + for (int r = 0; r < numRows; ++r) + { + const int st = semitoneForRow (r); + const float ly = oy + r * cellH + cellH * 0.5f; + + g.setFont (font (8.5f)); + g.setColour (colours::textDim); + g.drawText (juce::String (st > 0 ? "+" : "") + juce::String (st), + juce::roundToInt (labelW * 0.1f), juce::roundToInt (ly - 5.0f), juce::roundToInt (labelW * 0.6f), 10, + juce::Justification::centredRight); + + g.setColour (st % 12 == 0 ? colours::accent : colours::text); + g.drawText (noteNameForOffset (st), + juce::roundToInt (labelW * 0.62f), juce::roundToInt (ly - 5.0f), juce::roundToInt (labelW * 0.4f), 10, + juce::Justification::centredRight); + } + + // active cells + for (int i = 0; i < numSteps; ++i) + { + const auto& s = processor.getSequencer().step (i); + + if (! s.gate) + continue; + + const int r = rowForSemitone (s.semitone); + + const auto cell = juce::Rectangle (ox + i * cellW + 1.5f, oy + r * cellH + 1.5f, + cellW - 3.0f, cellH - 3.0f); + + const bool isSelected = (i == selectedStep); + const float radius = 3.0f; + + // cell body + juce::ColourGradient grad (isSelected ? colours::accent : colours::activeCell, + cell.getTopLeft(), + isSelected ? colours::accent2 : colours::activeCell.darker (0.6f), + cell.getBottomRight(), + 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) + { + g.setColour (colours::bg.withAlpha (0.85f)); + g.setFont (font (7.5f)); + g.drawText (formatCents (s.cents), cell.toNearestInt().reduced (1, 1), juce::Justification::topLeft); + } + } + + // --- slide row (bottom row) ---------------------------------------------- + const float slideY = oy + numRows * cellH; + + g.setColour (colours::accentDim); + g.drawHorizontalLine (juce::roundToInt (slideY - 1.0f), ox, ox + (float) bounds.getWidth()); + + g.setFont (font (9.0f)); + g.setColour (colours::textDim); + g.drawText ("SLIDE", + juce::roundToInt (labelW * 0.1f), juce::roundToInt (slideY + cellH * 0.5f - 6.0f), + juce::roundToInt (labelW * 0.8f), 12, + juce::Justification::centredRight); + + for (int i = 0; i < numSteps; ++i) + { + if (! processor.getSequencer().step (i).slide) + continue; + + const auto cell = juce::Rectangle (ox + i * cellW + 1.5f, slideY + 1.5f, + cellW - 3.0f, cellH - 3.0f); + + g.setColour (colours::accent); + g.fillRoundedRectangle (cell, 3.0f); + + const float ax = cell.getX() + cell.getWidth() * 0.5f; + const float ay = cell.getY() + cell.getHeight() * 0.5f; + + g.setColour (colours::bg); + juce::Path tri; + tri.addTriangle (ax - 3.0f, ay - 2.0f, ax - 3.0f, ay + 2.0f, ax + 3.5f, ay); + g.fillPath (tri); + } + + // --- dim columns beyond the pattern length --------------------------------- + if (seqLen < numSteps) + { + g.setColour (colours::bg.withAlpha (0.55f)); + g.fillRect (ox + seqLen * cellW, oy, + (numSteps - seqLen) * cellW, (float) bounds.getHeight()); + } + + // --- play position marker --------------------------------------------------- + if (playPos >= 0) + { + const float px = ox + playPos * cellW + cellW * 0.5f; + + g.setColour (colours::playMarker.withAlpha (0.65f)); + g.drawVerticalLine (juce::roundToInt (px - 1.0f), oy, oy + (float) bounds.getHeight()); + g.drawVerticalLine (juce::roundToInt (px), oy, oy + (float) bounds.getHeight()); + + g.setColour (colours::playMarker); + juce::Path tri; + tri.addTriangle (px - 3.0f, oy - headerH + 2.0f, px + 3.0f, oy - headerH + 2.0f, px, oy - 1.0f); + g.fillPath (tri); + } +} + +SequencerMatrix::CellRect SequencerMatrix::cellAt (const juce::MouseEvent& e) const +{ + const auto bounds = matrixBounds(); + + const int col = (int) std::floor ((e.getPosition().getX() - bounds.getX()) / cellW); + const int row = (int) std::floor ((e.getPosition().getY() - bounds.getY()) / cellH); + + const bool hit = bounds.contains (e.getPosition()) + && col >= 0 && col < numSteps + && row >= 0 && row < numRows + 1; + + return { juce::jlimit (0, numSteps - 1, col), juce::jlimit (0, numRows, row), hit }; +} + +void SequencerMatrix::mouseDown (const juce::MouseEvent& e) +{ + const auto cell = cellAt (e); + + if (! cell.hit) + return; + + selectedStep = cell.step; + + if (onStepSelected) + onStepSelected (cell.step); + + if (cell.row == numRows) + { + slideDragging = true; + slideLastStep = cell.step; + startedSlideActive = processor.getSequencer().step (cell.step).slide; + + if (onSlideChanged) + onSlideChanged (cell.step, ! startedSlideActive); + + repaint(); + return; + } + + if (e.mods.isAltDown()) + { + fineDragging = true; + fineRefRow = cell.row; + fineStartCents = processor.getSequencer().step (cell.step).cents; + fineLastCents = -1000.0f; + repaint(); + return; + } + + dragging = true; + moved = false; + dragStep = cell.step; + lastRow = cell.row; + + const auto& s = processor.getSequencer().step (cell.step); + startedActive = (s.gate && s.semitone == semitoneForRow (cell.row)); +} + +void SequencerMatrix::mouseDrag (const juce::MouseEvent& e) +{ + if (fineDragging) + { + const auto cell = cellAt (e); + + if (! cell.hit) + return; + + // one matrix row (one semitone) maps to 4 cents of fine tuning + const float cents = juce::jlimit (-50.0f, 50.0f, + fineStartCents + (fineRefRow - cell.row) * 4.0f); + + if (std::fabs (cents - fineLastCents) >= 0.5f) + { + fineLastCents = cents; + + if (onFineChanged) + onFineChanged (selectedStep, cents); + + repaint(); + } + + return; + } + + if (slideDragging) + { + const auto cell = cellAt (e); + + if (cell.hit && cell.row == numRows && cell.step != slideLastStep) + { + slideLastStep = cell.step; + + if (onSlideChanged) + onSlideChanged (cell.step, ! startedSlideActive); + + repaint(); + } + + return; + } + + if (! dragging) + return; + + const auto cell = cellAt (e); + + if (cell.hit && cell.row < numRows && cell.row != lastRow) + { + lastRow = cell.row; + moved = true; + + const int pitch = semitoneForRow (cell.row); + + if (startedActive) + { + if (onNoteChanged) + onNoteChanged (dragStep, pitch); + } + else + { + if (onGateChanged) + onGateChanged (dragStep, true); + + if (onNoteChanged) + onNoteChanged (dragStep, pitch); + } + + repaint(); + } +} + +void SequencerMatrix::mouseUp (const juce::MouseEvent& e) +{ + if (fineDragging) + { + fineDragging = false; + return; + } + + if (slideDragging) + { + slideDragging = false; + return; + } + + if (! dragging) + return; + + dragging = false; + + const auto cell = cellAt (e); + + if (! cell.hit || cell.step != dragStep || cell.row >= numRows) + return; + + const int pitch = semitoneForRow (cell.row); + + if (startedActive && ! moved) + { + if (onGateChanged) + onGateChanged (cell.step, false); + } + else if (! startedActive && ! moved) + { + if (onGateChanged) + onGateChanged (cell.step, true); + + if (onNoteChanged) + onNoteChanged (cell.step, pitch); + } +} diff --git a/Source/ui/SequencerMatrix.h b/Source/ui/SequencerMatrix.h new file mode 100644 index 0000000..439a303 --- /dev/null +++ b/Source/ui/SequencerMatrix.h @@ -0,0 +1,67 @@ +#pragma once + +#include + +class MonoStepAudioProcessor; + +class SequencerMatrix final : public juce::Component +{ +public: + explicit SequencerMatrix (MonoStepAudioProcessor& processor); + ~SequencerMatrix() override = default; + + std::function onStepSelected; + std::function onGateChanged; + std::function onNoteChanged; + std::function onFineChanged; + std::function onSlideChanged; + + void setSelectedStep (int stepIdx); + int getSelectedStep() const { return selectedStep; } + + void setPlayPosition (int stepIdx); + + void paint (juce::Graphics&) override; + void mouseDown (const juce::MouseEvent&) override; + void mouseDrag (const juce::MouseEvent&) override; + void mouseUp (const juce::MouseEvent&) override; + +private: + struct CellRect + { + int step; + int row; + bool hit; + }; + + CellRect cellAt (const juce::MouseEvent&) const; + juce::Rectangle matrixBounds() const; + int rowForSemitone (int semitone) const; + int semitoneForRow (int row) const; + + MonoStepAudioProcessor& processor; + + float cellW = 24.0f; + float cellH = 17.0f; + float labelW = 40.0f; + float headerH = 24.0f; + float headerPad = 10.0f; + + int selectedStep = 0; + int playPos = -1; + + bool dragging = false; + bool startedActive = false; + bool moved = false; + int lastRow = 0; + int dragStep = 0; + + bool fineDragging = false; + int fineRefRow = 0; + float fineStartCents = 0.0f; + float fineLastCents = -1000.0f; + + bool slideDragging = false; + bool startedSlideActive = false; + int slideLastStep = -1; +}; diff --git a/tests/TestHost.cpp b/tests/TestHost.cpp new file mode 100644 index 0000000..99be4d7 --- /dev/null +++ b/tests/TestHost.cpp @@ -0,0 +1,235 @@ +#include +#include "PluginProcessor.h" + +#include + +static double measureRms (const juce::AudioBuffer& buffer) +{ + double sum = 0.0; + int samples = 0; + + for (int c = 0; c < buffer.getNumChannels(); ++c) + for (int i = 0; i < buffer.getNumSamples(); ++i) + { + const float s = buffer.getSample (c, i); + sum += (double) s * s; + ++samples; + } + + return std::sqrt (sum / (double) std::max (1, samples)); +} + +static bool patternsEqual (const monostep::StepSequencer& a, const monostep::StepSequencer& b) +{ + for (int i = 0; i < monostep::numSteps; ++i) + { + const auto& sa = a.step (i); + const auto& sb = b.step (i); + + if (sa.gate != sb.gate || sa.semitone != sb.semitone + || std::fabs (sa.cents - sb.cents) > 0.001f || sa.slide != sb.slide) + return false; + } + + return true; +} + +// Part A: verify the actual .vst3 binary loads as a VST3 module. +static int testVst3Binary (const juce::File& pluginFile) +{ + juce::VST3PluginFormatHeadless format; + + juce::OwnedArray types; + format.findAllTypesForFile (types, pluginFile.getFullPathName()); + + if (types.isEmpty()) + { + std::cout << "FAILED: VST3 binary did not scan as a valid VST3 plugin\n"; + return 1; + } + + std::cout << "VST3 scan OK - found " << types.size() << " type(s): " + << types[0]->name << " (uid " << juce::String::toHexString ((int) types[0]->uniqueId) << ")\n"; + + if (! format.doesPluginStillExist (*types[0])) + { + std::cout << "FAILED: plugin file does not exist per format check\n"; + return 1; + } + + return 0; +} + +// Part B: drive the actual processor source and render audio. +static int testProcessorRendering() +{ + MonoStepAudioProcessor processor; + processor.prepareToPlay (44100.0, 512); + + const int totalSamples = (int) (44100.0 * 8.0); + const int blockSize = 512; + const int numBlocks = totalSamples / blockSize; + + juce::AudioBuffer block (2, blockSize); + juce::AudioBuffer out (2, totalSamples); + juce::MidiBuffer midi; + + // A held MIDI note is what gates the sequencer into running. + midi.addEvent (juce::MidiMessage::noteOn (1, 60, 0.9f), 0); + + for (int b = 0; b < numBlocks; ++b) + { + block.clear(); + processor.processBlock (block, midi); + + for (int c = 0; c < 2; ++c) + out.copyFrom (c, b * blockSize, block, c, 0, blockSize); + } + + const double rms = measureRms (out); + std::cout << "Rendered " << totalSamples << " samples, RMS = " << rms << "\n"; + + if (rms < 1e-4) + { + std::cout << "FAILED: output is silent\n"; + return 1; + } + + // Without a MIDI note the sequencer must stay silent. + MonoStepAudioProcessor idleProcessor; + idleProcessor.prepareToPlay (44100.0, 512); + + double idleSum = 0.0; + juce::MidiBuffer emptyMidi; + + for (int b = 0; b < numBlocks; ++b) + { + block.clear(); + emptyMidi.clear(); + idleProcessor.processBlock (block, emptyMidi); + + for (int c = 0; c < 2; ++c) + for (int i = 0; i < blockSize; ++i) + idleSum += (double) block.getSample (c, i) * block.getSample (c, i); + } + + if (std::sqrt (idleSum / (double) (numBlocks * blockSize * 2)) > 1e-5) + { + std::cout << "FAILED: sequencer plays without a MIDI trigger\n"; + return 1; + } + + std::cout << "Silent without MIDI trigger OK\n"; + + // parameter sanity + std::cout << "Num parameters: " << processor.getNumParameters() << "\n"; + + // pattern editing + fine tune + processor.setStepGate (0, true); + processor.setStepNote (0, 7); + processor.setStepCents (0, 42.0f); + processor.setStepSlide (1, true); + + if (std::fabs (processor.getSequencer().step (0).cents - 42.0f) > 0.001f) + { + std::cout << "FAILED: fine-tune per step did not apply\n"; + return 1; + } + + // state round trip through a fresh instance + juce::MemoryBlock state; + processor.getStateInformation (state); + std::cout << "State bytes: " << (int) state.getSize() << "\n"; + + MonoStepAudioProcessor processor2; + processor2.prepareToPlay (44100.0, 512); + processor2.setStateInformation (state.getData(), (int) state.getSize()); + + for (int i = 0; i < 4; ++i) + { + const auto& a = processor.getSequencer().step (i); + const auto& b = processor2.getSequencer().step (i); + std::cout << "step " << i << ": gate=" << a.gate << "/" << b.gate + << " note=" << a.semitone << "/" << b.semitone + << " cents=" << a.cents << "/" << b.cents + << " slide=" << a.slide << "/" << b.slide << "\n"; + } + + if (! patternsEqual (processor.getSequencer(), processor2.getSequencer())) + { + std::cout << "FAILED: pattern did not survive state save/load\n"; + return 1; + } + + std::cout << "State round-trip preserves pattern OK\n"; + + // pattern length parameter + if (processor.getPatternLength() != 16) + { + std::cout << "FAILED: default pattern length is not 16\n"; + return 1; + } + + *processor.getAPVTS().getRawParameterValue ("seqLen") = 8.0f; + + if (processor.getPatternLength() != 8) + { + std::cout << "FAILED: pattern length did not change\n"; + return 1; + } + + std::cout << "Pattern length OK\n"; + + *processor.getAPVTS().getRawParameterValue ("seqLen") = 16.0f; + + // master = 0 should silence the output while a note is held + *processor.getAPVTS().getRawParameterValue ("master") = 0.0f; + processor.reset(); + processor.prepareToPlay (44100.0, 512); + + double silentSum = 0.0; + for (int b = 0; b < numBlocks; ++b) + { + block.clear(); + midi.clear(); + + if (b == 0) + midi.addEvent (juce::MidiMessage::noteOn (1, 60, 0.9f), 0); + + processor.processBlock (block, midi); + + for (int c = 0; c < 2; ++c) + for (int i = 0; i < blockSize; ++i) + silentSum += (double) block.getSample (c, i) * block.getSample (c, i); + } + + if (std::sqrt (silentSum / (double) (numBlocks * blockSize * 2)) > 1e-5) + { + std::cout << "FAILED: master=0 did not silence output\n"; + return 1; + } + + std::cout << "Master=0 silences output OK\n"; + + return 0; +} + +int main (int argc, char** argv) +{ + if (argc < 2) + { + std::cout << "Usage: MonoStepTestHost [out.wav]\n"; + return 1; + } + + const juce::File pluginFile (argv[1]); + + if (testVst3Binary (pluginFile) != 0) + return 1; + + if (testProcessorRendering() != 0) + return 1; + + std::cout << "ALL TESTS PASSED\n"; + return 0; +}