From 4271fc34bb5ea9bbc78494692045dc9eaaa63e24 Mon Sep 17 00:00:00 2001 From: Armin Date: Wed, 5 Aug 2026 23:12:17 +0200 Subject: [PATCH] Prototype --- CMakeLists.txt | 30 ++-- Makefile | 10 +- README.md | 22 +-- Source/PluginEditor.cpp | 281 +++++++++++++++++++++++++---- Source/PluginEditor.h | 56 +++++- Source/PluginProcessor.cpp | 321 +++++++++++++++++++++++++++++++--- Source/PluginProcessor.h | 31 +++- Source/Theme.h | 2 +- Source/dsp/StepSequencer.h | 15 ++ Source/dsp/SynthVoice.h | 46 ++++- Source/dsp/WaveTables.h | 32 +++- Source/ui/SequencerMatrix.cpp | 157 ++++++++++++----- Source/ui/SequencerMatrix.h | 16 +- tests/TestHost.cpp | 100 ++++++++++- 14 files changed, 950 insertions(+), 169 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c30ed14..eac6ef1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.22) -project(MonoStep VERSION 1.0.0 LANGUAGES C CXX) +project(Monostep VERSION 1.0.0 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -63,32 +63,32 @@ endif() add_subdirectory("${JUCE_DIR}" juce_build) -juce_add_plugin(MonoStep +juce_add_plugin(Monostep VERSION 1.0.0 COMPANY_NAME "armin" BUNDLE_ID "com.armin.monostep" FORMATS VST3 - PRODUCT_NAME "MonoStep" + PRODUCT_NAME "Monostep" PLUGIN_MANUFACTURER_CODE Armn PLUGIN_CODE MStp DESCRIPTION "Monophonic 2-oscillator step-sequencer synth" - EDITOR_NAME MonoStepAudioProcessorEditor + EDITOR_NAME MonostepAudioProcessorEditor IS_SYNTH TRUE NEEDS_MIDI_INPUT TRUE VST3_AUTO_MANIFEST TRUE ) -juce_generate_juce_header(MonoStep) +juce_generate_juce_header(Monostep) -target_sources(MonoStep PRIVATE +target_sources(Monostep PRIVATE Source/PluginProcessor.cpp Source/PluginEditor.cpp Source/ui/SequencerMatrix.cpp ) -target_include_directories(MonoStep PRIVATE Source) +target_include_directories(Monostep PRIVATE Source) -target_compile_definitions(MonoStep PRIVATE +target_compile_definitions(Monostep PRIVATE JUCE_WEB_BROWSER=0 JUCE_VST3_CAN_REPLACE_VST2=0 MONOSTEP_GIT_INFO="${MONOSTEP_GIT_STRING}" @@ -97,20 +97,20 @@ target_compile_definitions(MonoStep PRIVATE option(MONOSTEP_BUILD_TESTS "Build the headless host test" ON) if(MONOSTEP_BUILD_TESTS) - juce_add_console_app(MonoStepTestHost - PRODUCT_NAME MonoStepTestHost + juce_add_console_app(MonostepTestHost + PRODUCT_NAME MonostepTestHost ) - juce_generate_juce_header(MonoStepTestHost) - target_link_libraries(MonoStepTestHost PRIVATE + 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 + target_include_directories(MonostepTestHost PRIVATE Source) + target_sources(MonostepTestHost PRIVATE Source/PluginProcessor.cpp tests/TestHost.cpp ) - target_compile_definitions(MonoStepTestHost PRIVATE + target_compile_definitions(MonostepTestHost PRIVATE MONOSTEP_HEADLESS=1 JUCE_PLUGINHOST_VST3=1 ) diff --git a/Makefile b/Makefile index decb089..f123288 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ SHELL := /bin/bash BUILD_DIR := build -PLUGIN_DIR := build/MonoStep_artefacts/Release/VST3 -PLUGIN := MonoStep.vst3 +PLUGIN_DIR := build/Monostep_artefacts/Release/VST3 +PLUGIN := Monostep.vst3 VST3_DIR := $(HOME)/Library/Audio/Plug-Ins/VST3 -TEST_HOST := build/MonoStepTestHost_artefacts/Release/MonoStepTestHost +TEST_HOST := build/MonostepTestHost_artefacts/Release/MonostepTestHost .PHONY: all build install test clean @@ -13,7 +13,7 @@ all: install # Configure (if needed) and build the VST3 plugin. build: cmake -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=Release - cmake --build $(BUILD_DIR) --target MonoStep_VST3 + cmake --build $(BUILD_DIR) --target Monostep_VST3 # Build then copy the fresh plugin into the user VST3 directory. install: build @@ -23,7 +23,7 @@ install: build @echo "Installed $(PLUGIN) into $(VST3_DIR)" test: build - cmake --build $(BUILD_DIR) --target MonoStepTestHost + cmake --build $(BUILD_DIR) --target MonostepTestHost "$(TEST_HOST)" "$(PLUGIN_DIR)/$(PLUGIN)" clean: diff --git a/README.md b/README.md index 633f50d..512d827 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# MonoStep +# Monostep A monophonic 2-oscillator step-sequencer synthesizer implemented as a VST3 plugin with JUCE. @@ -10,7 +10,7 @@ A monophonic 2-oscillator step-sequencer synthesizer implemented as a VST3 plugi - Per-step gates, pitches (+/- 6 semitones), fine tuning (cents), and slide (legato) - Matrix editor with click-to-toggle steps, drag-to-pitch, and a slide row - Rotary-knob GUI with zoom (100% - 200%), a step panel with fine-tune, and a - live red play-position marker + live orange play-position highlight - Footer status bar showing the git commit and build date - Pattern stored in the plugin state (save/restore with the DAW) @@ -37,13 +37,13 @@ cmake -S . -B build cmake -S . -B build -DJUCE_DIR=/path/to/juce # 3. Build the plugin: -cmake --build build --target MonoStep_VST3 +cmake --build build --target Monostep_VST3 ``` The VST3 plugin is written to: ``` -build/MonoStep_artefacts/Release/VST3/MonoStep.vst3 +build/Monostep_artefacts/Release/VST3/Monostep.vst3 ``` ### Quick start with make @@ -58,11 +58,11 @@ make clean # remove the build directory ### Installing (manual copy) -The VST3 plugin ships as a macOS bundle (`MonoStep.vst3`). Copy the whole +The VST3 plugin ships as a macOS bundle (`Monostep.vst3`). Copy the whole bundle into your DAW's VST3 directory: ```sh -cp -R build/MonoStep_artefacts/Release/VST3/MonoStep.vst3 \ +cp -R build/Monostep_artefacts/Release/VST3/Monostep.vst3 \ ~/Library/Audio/Plug-Ins/VST3/ ``` @@ -71,19 +71,19 @@ Notes for macOS: - The per-user VST3 directory is `~/Library/Audio/Plug-Ins/VST3/`; create it with `mkdir -p` if it does not exist. A system-wide copy would go to `/Library/Audio/Plug-Ins/VST3/` but requires `sudo`. -- When updating, remove the old bundle first (`rm -rf` on the `MonoStep.vst3` +- When updating, remove the old bundle first (`rm -rf` on the `Monostep.vst3` inside the target directory) so stale files do not remain. - Your DAW may cache the plugin list — quit and relaunch it (or rescan plugins) - after installing before looking for MonoStep. + after installing before looking for Monostep. ## Running the tests Build the headless host test and run it against the built plugin: ```sh -cmake --build build --target MonoStepTestHost -build/MonoStepTestHost_artefacts/Release/MonoStepTestHost \ - build/MonoStep_artefacts/Release/VST3/MonoStep.vst3 +cmake --build build --target MonostepTestHost +build/MonostepTestHost_artefacts/Release/MonostepTestHost \ + build/Monostep_artefacts/Release/VST3/Monostep.vst3 ``` The host scans the VST3, renders a buffer, and verifies that the pattern diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 80fd08b..8700cb8 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -6,6 +6,19 @@ using namespace monostep; +namespace +{ +juce::ColourGradient orangeGradient (const juce::Rectangle& area) +{ + const auto top = area.getTopLeft().translated (area.getWidth() * 0.5f, 0.0f); + const auto bottom = area.getBottomLeft().translated (area.getWidth() * 0.5f, 0.0f); + + return juce::ColourGradient (colours::accent, top, + colours::accent.darker (0.35f), bottom, + false); +} +} // namespace + //============================================================================== EditorLookAndFeel::EditorLookAndFeel() { @@ -33,6 +46,19 @@ void EditorLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int w g.setColour (colours::gridLight); g.drawEllipse (centre.getX() - radius, centre.getY() - radius, radius * 2.0f, radius * 2.0f, 1.0f); + // Almost-vertical (-2°) shading overlay to give the dial a subtle 3D look. + constexpr float degToRad = juce::MathConstants::pi / 180.0f; + const float tiltSin = std::sin (-2.0f * degToRad); + const float tiltCos = std::cos (-2.0f * degToRad); + + 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); + g.setGradientFill (shading); + g.fillEllipse (centre.getX() - radius, centre.getY() - radius, radius * 2.0f, radius * 2.0f); + if (end - start < 6.0f) { juce::Path valueArc; @@ -40,19 +66,19 @@ void EditorLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int w radius * 0.85f, radius * 0.85f, 0.0f, start, angle, true); - g.setColour (colours::accent); + g.setGradientFill (orangeGradient (bounds)); 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); + // The pointer reaches out to the value arc so it touches the arc. + const float tickLen = radius * 0.85f + 1.0f; + g.setGradientFill (orangeGradient (bounds)); g.drawLine (centre.getX(), centre.getY(), centre.getX() + tickLen * std::sin (angle), centre.getY() - tickLen * std::cos (angle), 2.5f); + g.setColour (colours::accent); g.fillEllipse (centre.getX() - 2.5f, centre.getY() - 2.5f, 5.0f, 5.0f); } @@ -61,7 +87,11 @@ void EditorLookAndFeel::drawToggleButton (juce::Graphics& g, juce::ToggleButton& { const auto bounds = button.getLocalBounds().toFloat(); - g.setColour (button.getToggleState() ? colours::accent : colours::grid); + if (button.getToggleState()) + g.setGradientFill (orangeGradient (bounds)); + else + g.setColour (colours::grid); + g.fillRoundedRectangle (bounds, 4.0f); g.setFont (font (11.0f)); @@ -75,7 +105,7 @@ void EditorLookAndFeel::drawButtonBackground (juce::Graphics& g, juce::Button& b const auto bounds = button.getLocalBounds().toFloat().reduced (0.5f); if (button.getToggleState()) - g.setColour (colours::accent); + g.setGradientFill (orangeGradient (bounds)); else g.setColour (highlighted ? colours::gridLight : colours::grid); @@ -89,6 +119,81 @@ void EditorLookAndFeel::drawButtonText (juce::Graphics& g, juce::TextButton& but g.drawText (button.getButtonText(), button.getLocalBounds(), juce::Justification::centred); } +void EditorLookAndFeel::drawComboBox (juce::Graphics& g, int width, int height, bool, + int, int, int, int, juce::ComboBox&) +{ + const auto bounds = juce::Rectangle (1.0f, 1.0f, (float) (width - 2), (float) (height - 2)); + + g.setColour (colours::grid); + g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (colours::gridLight); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); + + const float cx = (float) (width - 15); + const float cy = (float) height * 0.5f; + + juce::Path arrow; + arrow.addTriangle (cx - 5.0f, cy - 3.0f, cx + 5.0f, cy - 3.0f, cx, cy + 3.5f); + g.setColour (colours::accent); + g.fillPath (arrow); +} + +juce::Font EditorLookAndFeel::getComboBoxFont (juce::ComboBox&) +{ + return font (11.0f); +} + +void EditorLookAndFeel::drawPopupMenuBackground (juce::Graphics& g, int width, int height) +{ + g.setColour (colours::panel); + g.fillRect (0, 0, width, height); + g.setColour (colours::gridLight); + g.drawRect (0.0f, 0.0f, (float) width, (float) height, 1.0f); +} + +void EditorLookAndFeel::drawPopupMenuItem (juce::Graphics& g, const juce::Rectangle& area, + bool isSeparator, bool isActive, bool isHighlighted, + bool isTicked, bool hasSubMenu, + const juce::String& text, const juce::String& shortcutKeyText, + const juce::Drawable* icon, const juce::Colour* textColour) +{ + (void) hasSubMenu; + (void) icon; + + if (isSeparator) + { + g.setColour (colours::grid); + g.fillRect (area.reduced (10, 0).withY (area.getCentreY()).withHeight (1)); + return; + } + + g.setColour (isHighlighted ? colours::gridLight : colours::panel); + g.fillRect (area); + + if (isTicked) + { + g.setColour (colours::accent); + g.fillEllipse ((float) (area.getRight() - 17), area.getCentreY() - 3.5f, 7.0f, 7.0f); + } + + g.setFont (font (11.0f)); + g.setColour (isActive ? (textColour != nullptr ? *textColour : colours::text) + : colours::textDim); + g.drawFittedText (text, area.withTrimmedLeft (12).withTrimmedRight (isTicked ? 28 : 10), + juce::Justification::centredLeft, 1); + + if (shortcutKeyText.isNotEmpty()) + { + g.setColour (colours::textDim); + g.drawFittedText (shortcutKeyText, area.withTrimmedRight (16), juce::Justification::centredRight, 1); + } +} + +juce::Font EditorLookAndFeel::getPopupMenuFont() +{ + return font (11.0f); +} + //============================================================================== Knob::Knob (const juce::String& title) { @@ -124,10 +229,14 @@ Knob::Knob (const juce::String& title) void Knob::resized() { const int w = getWidth(); + const int h = getHeight(); titleLabel.setBounds (0, 0, w, 14); - slider.setBounds (4, 15, w - 8, w - 8); - valueLabel.setBounds (0, w - 8 + 15, w, 12); + + const int valueH = 12; + const int sliderH = juce::jmax (20, juce::jmin (w - 8, h - 15 - valueH)); + slider.setBounds (4, 15, w - 8, sliderH); + valueLabel.setBounds (0, 15 + sliderH, w, valueH); } void Knob::updateValueLabel() @@ -234,7 +343,7 @@ void ChoiceBar::resized() } //============================================================================== -StepPanel::StepPanel (MonoStepAudioProcessor& processorRef) +StepPanel::StepPanel (MonostepAudioProcessor& processorRef) : processor (processorRef), fineKnob ("Fine") { @@ -284,6 +393,70 @@ StepPanel::StepPanel (MonoStepAudioProcessor& processorRef) clearButton.onClick = [this] { clearPattern(); }; addAndMakeVisible (clearButton); + shiftLeftButton.setButtonText ("<<"); + shiftLeftButton.onClick = [this] { processor.shiftPattern (-1); }; + addAndMakeVisible (shiftLeftButton); + + shiftRightButton.setButtonText (">>"); + shiftRightButton.onClick = [this] { processor.shiftPattern (1); }; + addAndMakeVisible (shiftRightButton); + + toolsCaption.setText ("TOOLS", juce::dontSendNotification); + toolsCaption.setJustificationType (juce::Justification::centredLeft); + toolsCaption.setFont (font (9.0f)); + toolsCaption.setColour (juce::Label::textColourId, colours::textDim); + addAndMakeVisible (toolsCaption); + + presetCombo.setTextWhenNothingSelected ("Pick a preset"); + presetCombo.setColour (juce::ComboBox::textColourId, colours::text); + presetCombo.setColour (juce::ComboBox::outlineColourId, colours::gridLight); + presetCombo.setColour (juce::ComboBox::backgroundColourId, colours::grid); + presetCombo.setColour (juce::ComboBox::arrowColourId, colours::accent); + for (int i = 0; i < processor.getNumPresets(); ++i) + presetCombo.addItem (processor.getPresetName (i), i + 1); + + presetCombo.onChange = [this] + { + const int id = presetCombo.getSelectedId(); + if (id > 0) + processor.applyPreset (id - 1); + }; + + addAndMakeVisible (presetCombo); + + randomizeButton.setButtonText ("Randomize"); + randomizeButton.onClick = [this] + { + if (randomizeToggles[RandomPattern].getToggleState()) + processor.randomizePattern(); + if (randomizeToggles[RandomOsc].getToggleState()) + processor.randomizeOsc(); + if (randomizeToggles[RandomFilter].getToggleState()) + processor.randomizeFilter(); + if (randomizeToggles[RandomEnv].getToggleState()) + processor.randomizeEnv(); + if (randomizeToggles[RandomSeq].getToggleState()) + processor.randomizeSeqSettings(); + if (randomizeToggles[RandomFx].getToggleState()) + processor.randomizeFx(); + }; + addAndMakeVisible (randomizeButton); + + randomizeCaption.setText ("RANDOMIZE", juce::dontSendNotification); + randomizeCaption.setJustificationType (juce::Justification::centredLeft); + randomizeCaption.setFont (font (9.0f)); + randomizeCaption.setColour (juce::Label::textColourId, colours::textDim); + addAndMakeVisible (randomizeCaption); + + static const char* groupNames[] = { "Pattern", "Osc", "Filter", "Env", "Length", "FX" }; + + for (int i = 0; i < RandomGroupCount; ++i) + { + randomizeToggles[i].setButtonText (groupNames[i]); + randomizeToggles[i].setToggleState (true, juce::dontSendNotification); + addAndMakeVisible (randomizeToggles[i]); + } + setSelectedStep (0); } @@ -328,6 +501,7 @@ void StepPanel::clearPattern() processor.setStepNote (i, 0); processor.setStepCents (i, 0.0f); processor.setStepSlide (i, false); + processor.setStepAccent (i, false); } refresh(); @@ -344,18 +518,37 @@ void StepPanel::resized() const int knobX = (w - 74) / 2; const int knobY = 68; - fineKnob.setBounds (knobX, knobY, 74, 74); + fineKnob.setBounds (knobX, knobY, 74, 94); - const int btnY = knobY + 25; + const int btnY = knobY + 102; 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); + 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); + shiftRightButton.setBounds (10 + w - 20 - shiftW, btnY + 50, shiftW, 26); + + toolsCaption.setBounds (10, btnY + 86, w - 20, 14); + presetCombo.setBounds (10, btnY + 102, w - 20, 26); + randomizeButton.setBounds (10, btnY + 134, w - 20, 26); + randomizeCaption.setBounds (10, btnY + 166, w - 20, 12); + + const int colW = (w - 20 - 4) / 2; + const int rowH = 20; + + for (int i = 0; i < RandomGroupCount; ++i) + { + const int col = i / 3; + const int row = i % 3; + randomizeToggles[i].setBounds (10 + col * (colW + 4), btnY + 180 + row * (rowH + 2), colW, rowH); + } } //============================================================================== -MonoStepAudioProcessorEditor::MonoStepAudioProcessorEditor (MonoStepAudioProcessor& processor) +MonostepAudioProcessorEditor::MonostepAudioProcessorEditor (MonostepAudioProcessor& processor) : AudioProcessorEditor (processor), processorRef (processor), content (*this), @@ -365,8 +558,12 @@ MonoStepAudioProcessorEditor::MonoStepAudioProcessorEditor (MonoStepAudioProcess rootKnob ("Root"), gateKnob ("Gate"), masterKnob ("Master"), + coarseKnob ("Coarse"), + coarseKnob2 ("Coarse"), detuneKnob ("Detune"), mixKnob ("Mix"), + fineKnob1 ("Fine"), + fineKnob2 ("Fine"), cutoffKnob ("Cutoff"), resKnob ("Res"), glideKnob ("Glide"), @@ -374,6 +571,9 @@ MonoStepAudioProcessorEditor::MonoStepAudioProcessorEditor (MonoStepAudioProcess decayKnob ("Decay"), sustainKnob ("Sustain"), releaseKnob ("Release"), + driveKnob ("Drive"), + ringKnob ("Ring"), + accentKnob ("Accent"), 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) {}), @@ -414,6 +614,7 @@ MonoStepAudioProcessorEditor::MonoStepAudioProcessorEditor (MonoStepAudioProcess 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 (coarseKnob, "osc2Coarse", [] (float v) { return juce::String (juce::roundToInt (v)) + " st"; }); 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"; }); @@ -423,6 +624,9 @@ 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 (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)) + "%"; }); content.addAndMakeVisible (matrix); content.addAndMakeVisible (stepPanel); @@ -466,6 +670,12 @@ MonoStepAudioProcessorEditor::MonoStepAudioProcessorEditor (MonoStepAudioProcess stepPanel.refresh(); }; + matrix.onAccentChanged = [this] (int stepIdx, bool accent) + { + processorRef.setStepAccent (stepIdx, accent); + stepPanel.refresh(); + }; + processorRef.onPatternChanged = [this] { refreshEditor(); }; startTimerHz (30); @@ -473,14 +683,14 @@ MonoStepAudioProcessorEditor::MonoStepAudioProcessorEditor (MonoStepAudioProcess refreshEditor(); } -MonoStepAudioProcessorEditor::~MonoStepAudioProcessorEditor() +MonostepAudioProcessorEditor::~MonostepAudioProcessorEditor() { stopTimer(); processorRef.onPatternChanged = nullptr; setLookAndFeel (nullptr); } -void MonoStepAudioProcessorEditor::timerCallback() +void MonostepAudioProcessorEditor::timerCallback() { const int step = processorRef.getCurrentStep(); @@ -491,27 +701,27 @@ void MonoStepAudioProcessorEditor::timerCallback() } } -void MonoStepAudioProcessorEditor::refreshEditor() +void MonostepAudioProcessorEditor::refreshEditor() { matrix.setSelectedStep (matrix.getSelectedStep()); stepPanel.refresh(); matrix.repaint(); } -void MonoStepAudioProcessorEditor::paint (juce::Graphics& g) +void MonostepAudioProcessorEditor::paint (juce::Graphics& g) { g.fillAll (colours::bg); } -void MonoStepAudioProcessorEditor::paintContent (juce::Graphics& g) +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.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); + g.drawText ("2 OSC Step-Sequencer Synth", 16, 34, 130, 12, juce::Justification::centredLeft); const auto drawCaption = [&] (const juce::Component& component, const juce::String& text) { @@ -538,14 +748,14 @@ void MonoStepAudioProcessorEditor::paintContent (juce::Graphics& g) g.drawText (buildinfo::footerText(), 16, footY + 4, baseWidth - 32, 12, juce::Justification::centredLeft); } -void MonoStepAudioProcessorEditor::setZoomFactor (float newZoom) +void MonostepAudioProcessorEditor::setZoomFactor (float newZoom) { zoomFactor = newZoom; setSize (juce::roundToInt (baseWidth * newZoom), juce::roundToInt (baseHeight * newZoom)); } -void MonoStepAudioProcessorEditor::resized() +void MonostepAudioProcessorEditor::resized() { const int targetW = juce::roundToInt (baseWidth * zoomFactor); const int targetH = juce::roundToInt (baseHeight * zoomFactor); @@ -563,18 +773,19 @@ void MonoStepAudioProcessorEditor::resized() const int w = baseWidth; const int h = baseHeight; - const int topBarH = 58; + const int topBarH = 78; const int bottomBarH = 96; const int pad = 12; + const int topBarY = (topBarH - 26) / 2; - lengthKnob.setBounds (128, 2, 54, 56); - rateBar.setBounds (192, 18, 210, 26); + lengthKnob.setBounds (128, 2, 54, 74); + rateBar.setBounds (192, topBarY, 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); + swingKnob.setBounds (422, 2, 54, 74); + rootKnob.setBounds (482, 2, 54, 74); + octaveBar.setBounds (548, topBarY, 170, 26); + zoomBar.setBounds (w - pad - 54 - 8 - 236, topBarY, 236, 26); + masterKnob.setBounds (w - pad - 54, 2, 54, 74); const int panelW = 200; const int midTop = topBarH; @@ -592,7 +803,7 @@ void MonoStepAudioProcessorEditor::resized() wave2Bar.setBounds (x, bottomY + 30, barW, 24); x += barW + 16; const int knobW = 56; - const int knobGap = 72; + const int knobGap = 60; const int knobY = bottomY + 2; const auto placeKnob = [&] (Knob& knob) @@ -601,6 +812,7 @@ void MonoStepAudioProcessorEditor::resized() x += knobGap; }; + placeKnob (coarseKnob); placeKnob (detuneKnob); placeKnob (mixKnob); placeKnob (cutoffKnob); @@ -610,4 +822,7 @@ void MonoStepAudioProcessorEditor::resized() placeKnob (decayKnob); placeKnob (sustainKnob); placeKnob (releaseKnob); + placeKnob (driveKnob); + placeKnob (ringKnob); + placeKnob (accentKnob); } diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index ce7ec01..cf4d3ef 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -3,7 +3,7 @@ #include #include "ui/SequencerMatrix.h" -class MonoStepAudioProcessor; +class MonostepAudioProcessor; class EditorLookAndFeel final : public juce::LookAndFeel_V4 { @@ -23,6 +23,17 @@ public: void drawButtonText (juce::Graphics&, juce::TextButton&, bool shouldDrawButtonAsHighlighted, bool shouldDrawButtonAsDown) override; + + void drawComboBox (juce::Graphics&, int width, int height, bool isMouseButtonDown, + int buttonX, int buttonY, int buttonW, int buttonH, juce::ComboBox&) override; + juce::Font getComboBoxFont (juce::ComboBox&) override; + + void drawPopupMenuBackground (juce::Graphics&, int width, int height) override; + void drawPopupMenuItem (juce::Graphics&, const juce::Rectangle& area, + bool isSeparator, bool isActive, bool isHighlighted, bool isTicked, bool hasSubMenu, + const juce::String& text, const juce::String& shortcutKeyText, + const juce::Drawable* icon, const juce::Colour* textColour) override; + juce::Font getPopupMenuFont() override; }; class Knob final : public juce::Component @@ -93,7 +104,7 @@ private: class StepPanel final : public juce::Component { public: - explicit StepPanel (MonoStepAudioProcessor& processor); + explicit StepPanel (MonostepAudioProcessor& processor); void setSelectedStep (int stepIdx); void refresh(); @@ -105,7 +116,7 @@ public: private: void clearPattern(); - MonoStepAudioProcessor& processor; + MonostepAudioProcessor& processor; int selectedStep = 0; bool updating = false; @@ -118,14 +129,34 @@ private: juce::TextButton nextButton; juce::Label hintLabel; juce::TextButton clearButton; + juce::TextButton shiftLeftButton; + juce::TextButton shiftRightButton; + + juce::Label toolsCaption; + juce::ComboBox presetCombo; + juce::TextButton randomizeButton; + juce::Label randomizeCaption; + + enum RandomGroup + { + RandomPattern, + RandomOsc, + RandomFilter, + RandomEnv, + RandomSeq, + RandomFx, + RandomGroupCount + }; + + juce::ToggleButton randomizeToggles[RandomGroupCount]; }; -class MonoStepAudioProcessorEditor final : public juce::AudioProcessorEditor, +class MonostepAudioProcessorEditor final : public juce::AudioProcessorEditor, public juce::Timer { public: - explicit MonoStepAudioProcessorEditor (MonoStepAudioProcessor& processor); - ~MonoStepAudioProcessorEditor() override; + explicit MonostepAudioProcessorEditor (MonostepAudioProcessor& processor); + ~MonostepAudioProcessorEditor() override; void paint (juce::Graphics&) override; void resized() override; @@ -138,15 +169,15 @@ private: class ContentComponent final : public juce::Component { public: - explicit ContentComponent (MonoStepAudioProcessorEditor& owner) : ownerRef (owner) {} + explicit ContentComponent (MonostepAudioProcessorEditor& owner) : ownerRef (owner) {} void paint (juce::Graphics& g) override { ownerRef.paintContent (g); } private: - MonoStepAudioProcessorEditor& ownerRef; + MonostepAudioProcessorEditor& ownerRef; }; - MonoStepAudioProcessor& processorRef; + MonostepAudioProcessor& processorRef; ContentComponent content; @@ -165,8 +196,12 @@ private: Knob rootKnob; Knob gateKnob; Knob masterKnob; + Knob coarseKnob; + Knob coarseKnob2; Knob detuneKnob; Knob mixKnob; + Knob fineKnob1; + Knob fineKnob2; Knob cutoffKnob; Knob resKnob; Knob glideKnob; @@ -174,6 +209,9 @@ private: Knob decayKnob; Knob sustainKnob; Knob releaseKnob; + Knob driveKnob; + Knob ringKnob; + Knob accentKnob; ChoiceBar rateBar; ChoiceBar octaveBar; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index ed296fb..090713f 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -8,30 +8,35 @@ static const char* paramId (int index) { static const char* ids[] = { - "osc1Wave", "osc2Wave", "detune", "mix", + "osc1Wave", "osc2Wave", "osc1Coarse", "osc2Coarse", "detune", "mix", "cutoff", "res", "attack", "decay", "sustain", "release", "glide", "master", - "seqLen", "seqRate", "seqRoot", "seqOctave", "seqSwing", "seqGateLen" + "seqLen", "seqRate", "seqRoot", "seqOctave", "seqSwing", "seqGateLen", + "drive", "ringmod", "accent", "fine1" }; return ids[index]; } enum ParamIndex { - pOsc1Wave, pOsc2Wave, pDetune, pMix, + pOsc1Wave, pOsc2Wave, pOsc1Coarse, pOsc2Coarse, pDetune, pMix, pCutoff, pRes, pAttack, pDecay, pSustain, pRelease, pGlide, pMaster, pSeqLen, pSeqRate, pSeqRoot, pSeqOctave, pSeqSwing, pSeqGateLen, + pDrive, pRingMod, pAccent, pFine1, numParams }; -MonoStepAudioProcessor::MonoStepAudioProcessor() +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)); + osc1CoarseParam = apvts.getRawParameterValue (paramId (pOsc1Coarse)); + osc2CoarseParam = apvts.getRawParameterValue (paramId (pOsc2Coarse)); detuneParam = apvts.getRawParameterValue (paramId (pDetune)); mixParam = apvts.getRawParameterValue (paramId (pMix)); + osc1PhaseParam = apvts.getRawParameterValue (paramId (pFine1)); cutoffParam = apvts.getRawParameterValue (paramId (pCutoff)); resParam = apvts.getRawParameterValue (paramId (pRes)); attackParam = apvts.getRawParameterValue (paramId (pAttack)); @@ -46,16 +51,22 @@ MonoStepAudioProcessor::MonoStepAudioProcessor() seqOctaveParam = apvts.getRawParameterValue (paramId (pSeqOctave)); seqSwingParam = apvts.getRawParameterValue (paramId (pSeqSwing)); seqGateLenParam = apvts.getRawParameterValue (paramId (pSeqGateLen)); + driveParam = apvts.getRawParameterValue (paramId (pDrive)); + ringModParam = apvts.getRawParameterValue (paramId (pRingMod)); + accentParam = apvts.getRawParameterValue (paramId (pAccent)); setDefaultPattern(); } -juce::AudioProcessorValueTreeState::ParameterLayout MonoStepAudioProcessor::createParameterLayout() +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 (pOsc1Wave), "Osc 1 Wave", 0, 7, 2)); + layout.add (std::make_unique (paramId (pOsc2Wave), "Osc 2 Wave", 0, 7, 3)); + + layout.add (std::make_unique (paramId (pOsc1Coarse), "Osc 1 Coarse", -12, 12, 0)); + layout.add (std::make_unique (paramId (pOsc2Coarse), "Osc 2 Coarse", -12, 12, 0)); layout.add (std::make_unique (paramId (pDetune), "Osc 2 Detune", juce::NormalisableRange (-100.0f, 100.0f), 0.0f, @@ -64,6 +75,10 @@ juce::AudioProcessorValueTreeState::ParameterLayout MonoStepAudioProcessor::crea layout.add (std::make_unique (paramId (pMix), "Osc Mix", juce::NormalisableRange (0.0f, 1.0f), 0.5f)); + layout.add (std::make_unique (paramId (pFine1), + "Phase Offset", juce::NormalisableRange (-1.0f, 1.0f), 0.0f, + juce::AudioParameterFloatAttributes().withLabel ("±1"))); + 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"))); @@ -109,10 +124,19 @@ 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 (pDrive), + "Distortion Drive", juce::NormalisableRange (0.0f, 1.0f), 0.0f)); + + layout.add (std::make_unique (paramId (pRingMod), + "Ring Mod", juce::NormalisableRange (0.0f, 1.0f), 0.0f)); + + layout.add (std::make_unique (paramId (pAccent), + "Accent", juce::NormalisableRange (0.0f, 1.0f), 0.7f)); + return layout; } -void MonoStepAudioProcessor::setDefaultPattern() +void MonostepAudioProcessor::setDefaultPattern() { struct Def { int step; bool gate; int semitone; bool slide; }; const Def defs[] = @@ -136,9 +160,199 @@ void MonoStepAudioProcessor::setDefaultPattern() sequencer.step (d.step).semitone = d.semitone; sequencer.step (d.step).slide = d.slide; } + + sequencer.step (0).accent = true; + sequencer.step (4).accent = true; + sequencer.step (8).accent = true; } -void MonoStepAudioProcessor::prepareToPlay (double sr, int samplesPerBlock) +void MonostepAudioProcessor::applyParamValue (const juce::String& id, float value) +{ + if (auto* param = apvts.getParameter (id)) + { + param->setValueNotifyingHost (param->getNormalisableRange().convertTo0to1 (value)); + apvts.getParameterAsValue (id) = value; + } +} + +int MonostepAudioProcessor::getNumPresets() const +{ + return 18; +} + +const char* MonostepAudioProcessor::getPresetName (int index) const +{ + static const char* names[] = + { + "Wet Tape", + "Acid Wash", + "Tin Can", + "Bent Toy", + "Neon Arcade", + "Glitch Garden", + "Underwater FM", + "Robo Funk", + "Lo-Fi Noir", + "Spectral Sweep", + "Broken Radio", + "Plasma Organ", + "Glass Bells", + "Warm Pad", + "Pulse Groove", + "Steel Pluck", + "Sub Rumble", + "Tape Bounce" + }; + + return names[juce::jlimit (0, getNumPresets() - 1, index)]; +} + +void MonostepAudioProcessor::applyPreset (int index) +{ + struct Preset + { + int osc1; int osc2; int coarse; float detune; float mix; + 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; + }; + + 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 + + // --- 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 + }; + + const auto& pr = presets[juce::jlimit (0, getNumPresets() - 1, index)]; + + applyParamValue ("osc1Wave", (float) pr.osc1); + applyParamValue ("osc2Wave", (float) pr.osc2); + applyParamValue ("osc2Coarse", (float) pr.coarse); + applyParamValue ("detune", pr.detune); + applyParamValue ("mix", pr.mix); + applyParamValue ("cutoff", pr.cutoff); + applyParamValue ("res", pr.res); + applyParamValue ("attack", pr.attack); + applyParamValue ("decay", pr.decay); + applyParamValue ("sustain", pr.sustain); + applyParamValue ("release", pr.release); + applyParamValue ("glide", pr.glide); + applyParamValue ("seqGateLen", pr.gateLen); + applyParamValue ("seqSwing", pr.swing); + applyParamValue ("seqRate", (float) pr.rate); + applyParamValue ("seqRoot", (float) pr.root); + applyParamValue ("seqOctave", (float) pr.octave); + applyParamValue ("drive", pr.drive); + applyParamValue ("ringmod", pr.ring); + applyParamValue ("accent", pr.accent); +} + +void MonostepAudioProcessor::randomizePattern() +{ + const int length = juce::roundToInt (seqLenParam->load()); + int filled = 0; + + for (int i = 0; i < monostep::numSteps; ++i) + { + auto& s = sequencer.step (i); + + if (i >= length) + { + s.gate = false; + continue; + } + + const float chance = (i == 0) ? 0.9f : 0.45f; + s.gate = random.nextFloat() < chance; + + if (s.gate) + { + ++filled; + s.semitone = random.nextInt (monostep::maxSemitone - monostep::minSemitone + 1) + monostep::minSemitone; + s.cents = random.nextFloat() * 100.0f - 50.0f; + s.slide = random.nextFloat() < 0.25f; + s.accent = random.nextFloat() < 0.3f; + } + } + + if (filled == 0) + { + auto& s = sequencer.step (random.nextInt (length)); + s.gate = true; + s.semitone = 0; + } + + if (onPatternChanged) + onPatternChanged(); +} + +void MonostepAudioProcessor::randomizeOsc() +{ + applyParamValue ("osc1Wave", (float) random.nextInt (4)); + applyParamValue ("osc2Wave", (float) random.nextInt (4)); + applyParamValue ("osc2Coarse", (float) (random.nextInt (25) - 12)); + applyParamValue ("detune", (float) (random.nextInt (101) - 50)); + applyParamValue ("mix", 0.2f + random.nextFloat() * 0.6f); +} + +void MonostepAudioProcessor::randomizeFilter() +{ + applyParamValue ("cutoff", 200.0f * std::pow (2.0f, random.nextFloat() * 5.0f)); + applyParamValue ("res", random.nextFloat() * 0.8f); +} + +void MonostepAudioProcessor::randomizeEnv() +{ + applyParamValue ("attack", 0.001f * std::pow (500.0f, random.nextFloat())); + applyParamValue ("decay", 0.05f * std::pow (30.0f, random.nextFloat())); + applyParamValue ("sustain", random.nextFloat()); + applyParamValue ("release", 0.05f * std::pow (40.0f, random.nextFloat())); + applyParamValue ("glide", random.nextFloat() * 0.5f); +} + +void MonostepAudioProcessor::randomizeSeqSettings() +{ + applyParamValue ("seqGateLen", 0.3f + random.nextFloat() * 0.7f); + applyParamValue ("seqSwing", random.nextFloat() * 0.4f); + applyParamValue ("seqRate", (float) random.nextInt (5)); + applyParamValue ("seqLen", (float) (random.nextInt (13) + 4)); +} + +void MonostepAudioProcessor::randomizeFx() +{ + applyParamValue ("drive", random.nextFloat()); + applyParamValue ("ringmod", random.nextFloat()); +} + +void MonostepAudioProcessor::randomizeParams() +{ + randomizeOsc(); + randomizeFilter(); + randomizeEnv(); + randomizeSeqSettings(); + randomizeFx(); +} + +void MonostepAudioProcessor::prepareToPlay (double sr, int samplesPerBlock) { sampleRate = sr; voice.prepare (sr); @@ -151,17 +365,18 @@ void MonoStepAudioProcessor::prepareToPlay (double sr, int samplesPerBlock) lastHostPpq = -1.0; } -void MonoStepAudioProcessor::releaseResources() +void MonostepAudioProcessor::releaseResources() { voice.reset(); } -void MonoStepAudioProcessor::updateVoiceParams() +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.detuneRatio = std::pow (2.0f, osc2CoarseParam->load() / 12.0f) + * std::pow (2.0f, detuneParam->load() / 1200.0f); p.mix = mixParam->load(); p.cutoff = cutoffParam->load(); p.resonance = resParam->load(); @@ -171,15 +386,17 @@ void MonoStepAudioProcessor::updateVoiceParams() p.release = releaseParam->load(); p.glide = glideParam->load(); p.master = masterParam->load(); + p.drive = driveParam->load(); + p.ringMod = ringModParam->load(); voice.setParams (p); } -float MonoStepAudioProcessor::midiToFreq (float note) const +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) +void MonostepAudioProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer& midiMessages) { juce::ScopedNoDenormals noDenormals; @@ -265,8 +482,12 @@ void MonoStepAudioProcessor::processBlock (juce::AudioBuffer& buffer, juc } else { + if (lastGateApplied) + voice.noteOff(); + lastStep = -1; lastGateApplied = false; + lastFreqApplied = -1.0f; playStep.store (-1); } @@ -277,6 +498,7 @@ void MonoStepAudioProcessor::processBlock (juce::AudioBuffer& buffer, juc bool seqGate = false; float seqFreq = 0.0f; bool seqSlide = false; + bool seqAccent = false; if (midiHeld) { @@ -307,10 +529,21 @@ void MonoStepAudioProcessor::processBlock (juce::AudioBuffer& buffer, juc const auto& s = sequencer.step (stepIdx); seqSlide = s.slide; + seqAccent = s.accent; + + // A step whose successor slides keeps its gate open until the next + // step so the glide into it is seamless; otherwise the gate closes + // after gateLen of the step. + const int nextIdx = (stepIdx + 1) % seqLen; + const bool slideIntoNext = sequencer.step (nextIdx).slide; + const float nextStart = (stepIdx + 1) * stepLen + + (((stepIdx + 1) % 2) ? swing * stepLen : 0.0f); - // gate length: the step's note sustains for gateLen of the step const float ppqWithinStep = ppqInCycle - stepStart; - seqGate = s.gate && (ppqWithinStep < stepLen * gateLen); + const float gateClose = slideIntoNext ? (nextStart - stepStart) + : stepLen * gateLen; + + seqGate = s.gate && (ppqWithinStep < gateClose); if (seqGate) { @@ -332,6 +565,10 @@ void MonoStepAudioProcessor::processBlock (juce::AudioBuffer& buffer, juc wantLegato = seqSlide; } + // Accent tracks the current step (not just gate changes), so it holds for + // the whole step even when the note repeats without retriggering. + voice.setAccentLevel (seqAccent ? 1.0f + accentParam->load() * 4.0f : 1.0f); + if (wantGate != lastGateApplied) { if (wantGate) @@ -357,7 +594,7 @@ void MonoStepAudioProcessor::processBlock (juce::AudioBuffer& buffer, juc buffer.addFrom (ch, 0, scratch, 0, 0, numSamples); } -void MonoStepAudioProcessor::setStepGate (int idx, bool gate) +void MonostepAudioProcessor::setStepGate (int idx, bool gate) { if (idx < 0 || idx >= monostep::numSteps) return; @@ -368,7 +605,7 @@ void MonoStepAudioProcessor::setStepGate (int idx, bool gate) onPatternChanged(); } -void MonoStepAudioProcessor::setStepNote (int idx, int semitone) +void MonostepAudioProcessor::setStepNote (int idx, int semitone) { if (idx < 0 || idx >= monostep::numSteps) return; @@ -379,7 +616,7 @@ void MonoStepAudioProcessor::setStepNote (int idx, int semitone) onPatternChanged(); } -void MonoStepAudioProcessor::setStepCents (int idx, float cents) +void MonostepAudioProcessor::setStepCents (int idx, float cents) { if (idx < 0 || idx >= monostep::numSteps) return; @@ -390,7 +627,7 @@ void MonoStepAudioProcessor::setStepCents (int idx, float cents) onPatternChanged(); } -void MonoStepAudioProcessor::setStepSlide (int idx, bool slide) +void MonostepAudioProcessor::setStepSlide (int idx, bool slide) { if (idx < 0 || idx >= monostep::numSteps) return; @@ -401,7 +638,26 @@ void MonoStepAudioProcessor::setStepSlide (int idx, bool slide) onPatternChanged(); } -juce::String MonoStepAudioProcessor::getStepNoteName (int idx) const +void MonostepAudioProcessor::setStepAccent (int idx, bool accent) +{ + if (idx < 0 || idx >= monostep::numSteps) + return; + + sequencer.step (idx).accent = accent; + + if (onPatternChanged) + onPatternChanged(); +} + +void MonostepAudioProcessor::shiftPattern (int dir) +{ + sequencer.shift (dir); + + 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; @@ -414,12 +670,13 @@ juce::String MonoStepAudioProcessor::getStepNoteName (int idx) const return juce::MidiMessage::getMidiNoteName (note, true, true, 3); } -void MonoStepAudioProcessor::storeSequence (juce::ValueTree& seq) const +void MonostepAudioProcessor::storeSequence (juce::ValueTree& seq) const { juce::String gates; juce::String notes; juce::String cents; juce::String slides; + juce::String accents; for (int i = 0; i < monostep::numSteps; ++i) { @@ -429,15 +686,17 @@ void MonoStepAudioProcessor::storeSequence (juce::ValueTree& seq) const notes += juce::String (s.semitone) + ","; cents += juce::String (s.cents, 2) + ","; slides += s.slide ? "1" : "0"; + accents += s.accent ? "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); + seq.setProperty ("accents", accents, nullptr); } -void MonoStepAudioProcessor::loadSequence (const juce::ValueTree& seq) +void MonostepAudioProcessor::loadSequence (const juce::ValueTree& seq) { const auto splitFloats = [] (const juce::String& text) { @@ -455,6 +714,7 @@ void MonoStepAudioProcessor::loadSequence (const juce::ValueTree& seq) const auto notes = splitFloats (seq.getProperty ("notes").toString()); const auto cents = splitFloats (seq.getProperty ("cents").toString()); const auto slides = seq.getProperty ("slides").toString(); + const auto accents = seq.getProperty ("accents").toString(); for (int i = 0; i < monostep::numSteps; ++i) { @@ -471,12 +731,15 @@ void MonoStepAudioProcessor::loadSequence (const juce::ValueTree& seq) if (i < slides.length()) s.slide = slides[i] == '1'; + + if (i < accents.length()) + s.accent = accents[i] == '1'; } } -void MonoStepAudioProcessor::getStateInformation (juce::MemoryBlock& destData) +void MonostepAudioProcessor::getStateInformation (juce::MemoryBlock& destData) { - juce::ValueTree state = apvts.state.createCopy(); + juce::ValueTree state = apvts.copyState(); juce::ValueTree seq = state.getOrCreateChildWithName ("SEQ", nullptr); storeSequence (seq); @@ -484,7 +747,7 @@ void MonoStepAudioProcessor::getStateInformation (juce::MemoryBlock& destData) copyXmlToBinary (*xml, destData); } -void MonoStepAudioProcessor::setStateInformation (const void* data, int sizeInBytes) +void MonostepAudioProcessor::setStateInformation (const void* data, int sizeInBytes) { std::unique_ptr xml (juce::AudioProcessor::getXmlFromBinary (data, sizeInBytes)); @@ -501,10 +764,10 @@ void MonoStepAudioProcessor::setStateInformation (const void* data, int sizeInBy onPatternChanged(); } -juce::AudioProcessorEditor* MonoStepAudioProcessor::createEditor() +juce::AudioProcessorEditor* MonostepAudioProcessor::createEditor() { #ifndef MONOSTEP_HEADLESS - return new MonoStepAudioProcessorEditor (*this); + return new MonostepAudioProcessorEditor (*this); #else return nullptr; #endif @@ -512,5 +775,5 @@ juce::AudioProcessorEditor* MonoStepAudioProcessor::createEditor() juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() { - return new MonoStepAudioProcessor(); + return new MonostepAudioProcessor(); } diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h index dd38f47..8f24828 100644 --- a/Source/PluginProcessor.h +++ b/Source/PluginProcessor.h @@ -4,11 +4,11 @@ #include "dsp/SynthVoice.h" #include "dsp/StepSequencer.h" -class MonoStepAudioProcessor final : public juce::AudioProcessor +class MonostepAudioProcessor final : public juce::AudioProcessor { public: - MonoStepAudioProcessor(); - ~MonoStepAudioProcessor() override = default; + MonostepAudioProcessor(); + ~MonostepAudioProcessor() override = default; void prepareToPlay (double sampleRate, int samplesPerBlock) override; void releaseResources() override; @@ -17,7 +17,7 @@ public: juce::AudioProcessorEditor* createEditor() override; bool hasEditor() const override { return true; } - const juce::String getName() const override { return "MonoStep"; } + 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; } @@ -43,6 +43,19 @@ public: void setStepNote (int idx, int semitone); void setStepCents (int idx, float cents); void setStepSlide (int idx, bool slide); + void setStepAccent (int idx, bool accent); + void shiftPattern (int dir); + + int getNumPresets() const; + const char* getPresetName (int index) const; + void applyPreset (int index); + void randomizePattern(); + void randomizeParams(); + void randomizeOsc(); + void randomizeFilter(); + void randomizeEnv(); + void randomizeSeqSettings(); + void randomizeFx(); juce::String getStepNoteName (int idx) const; @@ -52,6 +65,7 @@ private: static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout(); void updateVoiceParams(); + void applyParamValue (const juce::String& id, float value); float midiToFreq (float note) const; void storeSequence (juce::ValueTree& seq) const; void loadSequence (const juce::ValueTree& seq); @@ -61,8 +75,11 @@ private: std::atomic* osc1WaveParam = nullptr; std::atomic* osc2WaveParam = nullptr; + std::atomic* osc1CoarseParam = nullptr; + std::atomic* osc2CoarseParam = nullptr; std::atomic* detuneParam = nullptr; std::atomic* mixParam = nullptr; + std::atomic* osc1PhaseParam = nullptr; std::atomic* cutoffParam = nullptr; std::atomic* resParam = nullptr; std::atomic* attackParam = nullptr; @@ -75,12 +92,16 @@ private: std::atomic* seqRateParam = nullptr; std::atomic* seqRootParam = nullptr; std::atomic* seqOctaveParam = nullptr; - std::atomic* seqSwingParam = nullptr; + std::atomic* seqSwingParam = nullptr; std::atomic* seqGateLenParam = nullptr; + std::atomic* driveParam = nullptr; + std::atomic* ringModParam = nullptr; + std::atomic* accentParam = nullptr; monostep::StepSequencer sequencer; monostep::SynthVoice voice; juce::AudioBuffer scratch; + juce::Random random; double sampleRate = 44100.0; diff --git a/Source/Theme.h b/Source/Theme.h index 62cb3e2..92e5d7a 100644 --- a/Source/Theme.h +++ b/Source/Theme.h @@ -15,7 +15,7 @@ namespace colours 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 accent2 (0xFFE8A33D); inline const juce::Colour accentDim (0xFF7A5A26); inline const juce::Colour activeCell (0xFF3A6EA5); inline const juce::Colour selected (0xFFF0F4F8); diff --git a/Source/dsp/StepSequencer.h b/Source/dsp/StepSequencer.h index 7a63ffd..8e11cc0 100644 --- a/Source/dsp/StepSequencer.h +++ b/Source/dsp/StepSequencer.h @@ -17,6 +17,7 @@ struct Step int semitone = 0; // -12 .. +12 float cents = 0.0f; // -50 .. +50 bool slide = false; + bool accent = false; }; class StepSequencer @@ -31,6 +32,20 @@ public: s = Step{}; } + void shift (int dir) + { + if (dir == 0) + return; + + std::array rotated = steps; + + for (int i = 0; i < numSteps; ++i) + { + const int from = (i - dir + numSteps) % numSteps; + steps[i] = rotated[from]; + } + } + int stepsPerBeatFromRate (int rateIndex) const { // 0: 1/4, 1: 1/8, 2: 1/8T, 3: 1/16, 4: 1/32 diff --git a/Source/dsp/SynthVoice.h b/Source/dsp/SynthVoice.h index 85339bc..0357f4b 100644 --- a/Source/dsp/SynthVoice.h +++ b/Source/dsp/SynthVoice.h @@ -13,8 +13,12 @@ public: { Waveform waveA = Waveform::saw; Waveform waveB = Waveform::square; - float detuneRatio = 1.0f; - float mix = 0.5f; + float osc1Coarse = 0.0f; + float osc2Coarse = 0.0f; + float osc1Fine = 0.0f; + float osc2Fine = 0.0f; + float osc1Phase = 0.0f; + float osc2Phase = 0.0f; float cutoff = 7000.0f; float resonance = 0.15f; float attack = 0.005f; @@ -23,11 +27,22 @@ public: float release = 0.4f; float glide = 0.12f; float master = 0.9f; + float drive = 0.0f; + float ringMod = 0.0f; + float detuneRatio = 1.0f; + float mix = 0.5f; }; + void addPhase (float phaseOffset) + { + phaseA = frac (phaseA + phaseOffset); + phaseB = frac (phaseB + phaseOffset); + } + void prepare (double sr) { sampleRate = (float) sr; + accentSmoothCoef = 1.0f - std::exp (-1.0f / (0.003f * sampleRate)); reset(); } @@ -40,12 +55,16 @@ public: env = 0.0f; envStage = Stage::idle; gate = false; + accentLevel = 1.0f; + accentSmooth = 1.0f; } void setParams (const Params& p) { params = p; } bool isActive() const { return gate; } + void setAccentLevel (float level) { accentLevel = level; } + void noteOn (float freqHz, bool legato) { targetFreq = freqHz; @@ -153,6 +172,9 @@ private: updateEnvelope(); updateGlide(); + // Smooth the accent gain so it ramps in/out instead of clicking. + accentSmooth += (accentLevel - accentSmooth) * accentSmoothCoef; + const float incA = currentFreq / sampleRate; const float incB = (currentFreq * params.detuneRatio) / sampleRate; @@ -164,9 +186,24 @@ private: float out = (1.0f - params.mix) * a + params.mix * b; + if (params.ringMod > 0.0f) + out = (1.0f - params.ringMod) * out + params.ringMod * (a * b); + + const float preFilter = out; out = processFilter (out); - return out * env * params.master; + // Accented steps also get a touch of the unfiltered signal so they cut + // through the mix (presence), not just a volume bump. + if (accentSmooth > 1.0f) + out += (accentSmooth - 1.0f) * 0.2f * preFilter; + + if (params.drive > 0.0f) + { + const float wet = std::tanh (out * (1.0f + params.drive * 9.0f)); + out = (1.0f - params.drive) * out + params.drive * wet; + } + + return out * env * params.master * accentSmooth; } float sampleRate = 44100.0f; @@ -184,6 +221,9 @@ private: float env = 0.0f; Stage envStage = Stage::idle; bool gate = false; + float accentLevel = 1.0f; + float accentSmooth = 1.0f; + float accentSmoothCoef = 0.01f; }; } // namespace monostep diff --git a/Source/dsp/WaveTables.h b/Source/dsp/WaveTables.h index f51fef2..34fce7b 100644 --- a/Source/dsp/WaveTables.h +++ b/Source/dsp/WaveTables.h @@ -2,6 +2,7 @@ #include #include +#include namespace monostep { @@ -11,7 +12,11 @@ enum class Waveform : int sine = 0, triangle = 1, saw = 2, - square = 3 + square = 3, + pulse = 4, + noise = 5, + sub = 6, + pluck = 7 }; inline float frac (float x) @@ -63,6 +68,31 @@ inline float renderWave (Waveform w, float phase, float inc) v -= polyBlep (frac (f + 0.5f), inc); return v; } + + case Waveform::pulse: + { + const float f = frac (phase); + return (f < 0.3f) ? 1.0f : -1.0f; + } + + case Waveform::sub: + { + const float f = frac (phase * 0.25f); + return 4.0f * std::min (f, 1.0f - f) - 1.0f; + } + + case Waveform::noise: + { + thread_local static std::mt19937 rng { std::random_device {}() }; + thread_local static std::uniform_real_distribution dist (-1.0f, 1.0f); + return dist (rng); + } + + case Waveform::pluck: + { + const float f = frac (phase); + return 2.0f * std::sin (f * 6.283185307179586f * 2.0f) * std::exp (-10.0f * f); + } } return 0.0f; diff --git a/Source/ui/SequencerMatrix.cpp b/Source/ui/SequencerMatrix.cpp index 2b7386a..264b257 100644 --- a/Source/ui/SequencerMatrix.cpp +++ b/Source/ui/SequencerMatrix.cpp @@ -5,7 +5,7 @@ using namespace monostep; -SequencerMatrix::SequencerMatrix (MonoStepAudioProcessor& processorRef) +SequencerMatrix::SequencerMatrix (MonostepAudioProcessor& processorRef) : processor (processorRef) { } @@ -22,13 +22,19 @@ int SequencerMatrix::semitoneForRow (int row) const juce::Rectangle SequencerMatrix::matrixBounds() const { + const int w = getWidth(); + const int h = getHeight(); + + if (w > labelW && h > headerH) + { + cellW = (float) (w - labelW) / (float) numSteps; + cellH = (float) (h - headerH) / (float) (numRows + 2); + } + const int gridW = juce::roundToInt (cellW * numSteps); - const int gridH = juce::roundToInt (cellH * (numRows + 1)); + const int gridH = juce::roundToInt (cellH * (numRows + 2)); - 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 }; + return { juce::roundToInt (labelW), juce::roundToInt (headerH), gridW, gridH }; } void SequencerMatrix::setSelectedStep (int stepIdx) @@ -65,9 +71,16 @@ void SequencerMatrix::paint (juce::Graphics& g) g.fillRect (bounds.toFloat()); // selected column highlight - g.setColour (colours::selected.withAlpha (0.07f)); + g.setColour (colours::selected.withAlpha (0.02f)); g.fillRect (ox + selectedStep * cellW, oy, cellW, (float) bounds.getHeight()); + // play position column highlight (drawn before cells so notes stay vivid) + if (playPos >= 0) + { + g.setColour (colours::accent.withAlpha (0.04f)); + g.fillRect (ox + playPos * cellW, oy, cellW, (float) bounds.getHeight()); + } + // grid lines g.setColour (colours::grid); @@ -77,7 +90,7 @@ void SequencerMatrix::paint (juce::Graphics& g) g.drawVerticalLine (juce::roundToInt (x), oy, oy + (float) bounds.getHeight()); } - for (int i = 0; i <= numRows + 1; ++i) + for (int i = 0; i <= numRows + 2; ++i) { const float y = oy + i * cellH; g.drawHorizontalLine (juce::roundToInt (y), ox, ox + (float) bounds.getWidth()); @@ -107,14 +120,6 @@ void SequencerMatrix::paint (juce::Graphics& g) 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 @@ -152,10 +157,10 @@ void SequencerMatrix::paint (juce::Graphics& g) 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(), + 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); g.setGradientFill (grad); @@ -215,6 +220,39 @@ void SequencerMatrix::paint (juce::Graphics& g) g.fillPath (tri); } + // --- accent row (bottom row) ---------------------------------------------- + const float accentY = oy + (numRows + 1) * cellH; + + g.setColour (colours::accentDim); + g.drawHorizontalLine (juce::roundToInt (accentY - 1.0f), ox, ox + (float) bounds.getWidth()); + + g.setFont (font (9.0f)); + g.setColour (colours::textDim); + g.drawText ("ACCENT", + juce::roundToInt (labelW * 0.1f), juce::roundToInt (accentY + 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).accent) + continue; + + const auto cell = juce::Rectangle (ox + i * cellW + 1.5f, accentY + 1.5f, + cellW - 3.0f, cellH - 3.0f); + + g.setColour (colours::accent2); + 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 - 2.0f, ay - 2.5f, ax + 2.0f, ay - 2.5f, ax, ay + 2.5f); + g.fillPath (tri); + } + // --- dim columns beyond the pattern length --------------------------------- if (seqLen < numSteps) { @@ -222,21 +260,6 @@ void SequencerMatrix::paint (juce::Graphics& g) 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 @@ -248,9 +271,9 @@ SequencerMatrix::CellRect SequencerMatrix::cellAt (const juce::MouseEvent& e) co const bool hit = bounds.contains (e.getPosition()) && col >= 0 && col < numSteps - && row >= 0 && row < numRows + 1; + && row >= -1 && row < numRows + 2; - return { juce::jlimit (0, numSteps - 1, col), juce::jlimit (0, numRows, row), hit }; + return { juce::jlimit (0, numSteps - 1, col), juce::jlimit (0, numRows + 1, row), hit }; } void SequencerMatrix::mouseDown (const juce::MouseEvent& e) @@ -260,11 +283,32 @@ void SequencerMatrix::mouseDown (const juce::MouseEvent& e) if (! cell.hit) return; + if (cell.row == -1) + { + selectedStep = cell.step; + if (onStepSelected) + onStepSelected (cell.step); + return; + } + selectedStep = cell.step; if (onStepSelected) onStepSelected (cell.step); + if (cell.row == numRows + 1) + { + accentDragging = true; + accentLastStep = cell.step; + startedAccentActive = processor.getSequencer().step (cell.step).accent; + + if (onAccentChanged) + onAccentChanged (cell.step, ! startedAccentActive); + + repaint(); + return; + } + if (cell.row == numRows) { slideDragging = true; @@ -299,13 +343,12 @@ void SequencerMatrix::mouseDown (const juce::MouseEvent& e) void SequencerMatrix::mouseDrag (const juce::MouseEvent& e) { + const auto cell = cellAt (e); + if (! cell.hit || cell.row == -1) + return; + 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); @@ -325,8 +368,6 @@ void SequencerMatrix::mouseDrag (const juce::MouseEvent& e) if (slideDragging) { - const auto cell = cellAt (e); - if (cell.hit && cell.row == numRows && cell.step != slideLastStep) { slideLastStep = cell.step; @@ -340,10 +381,26 @@ void SequencerMatrix::mouseDrag (const juce::MouseEvent& e) return; } + if (accentDragging) + { + if (cell.hit && cell.row == numRows + 1 && cell.step != accentLastStep) + { + accentLastStep = cell.step; + + if (onAccentChanged) + onAccentChanged (cell.step, ! startedAccentActive); + + repaint(); + } + + return; + } + if (! dragging) return; - const auto cell = cellAt (e); + if (cell.row >= numRows) + return; if (cell.hit && cell.row < numRows && cell.row != lastRow) { @@ -352,6 +409,8 @@ void SequencerMatrix::mouseDrag (const juce::MouseEvent& e) const int pitch = semitoneForRow (cell.row); + const auto& s = processor.getSequencer().step (cell.step); + if (startedActive) { if (onNoteChanged) @@ -384,6 +443,12 @@ void SequencerMatrix::mouseUp (const juce::MouseEvent& e) return; } + if (accentDragging) + { + accentDragging = false; + return; + } + if (! dragging) return; @@ -391,7 +456,7 @@ void SequencerMatrix::mouseUp (const juce::MouseEvent& e) const auto cell = cellAt (e); - if (! cell.hit || cell.step != dragStep || cell.row >= numRows) + if (! cell.hit || cell.step != dragStep || cell.row >= numRows || cell.row == -1) return; const int pitch = semitoneForRow (cell.row); diff --git a/Source/ui/SequencerMatrix.h b/Source/ui/SequencerMatrix.h index 439a303..280c3c1 100644 --- a/Source/ui/SequencerMatrix.h +++ b/Source/ui/SequencerMatrix.h @@ -2,12 +2,12 @@ #include -class MonoStepAudioProcessor; +class MonostepAudioProcessor; class SequencerMatrix final : public juce::Component { public: - explicit SequencerMatrix (MonoStepAudioProcessor& processor); + explicit SequencerMatrix (MonostepAudioProcessor& processor); ~SequencerMatrix() override = default; std::function onStepSelected; @@ -15,6 +15,7 @@ public: std::function onNoteChanged; std::function onFineChanged; std::function onSlideChanged; + std::function onAccentChanged; void setSelectedStep (int stepIdx); int getSelectedStep() const { return selectedStep; } @@ -39,14 +40,15 @@ private: int rowForSemitone (int semitone) const; int semitoneForRow (int row) const; - MonoStepAudioProcessor& processor; + MonostepAudioProcessor& processor; - float cellW = 24.0f; - float cellH = 17.0f; float labelW = 40.0f; float headerH = 24.0f; float headerPad = 10.0f; + mutable float cellW = 24.0f; + mutable float cellH = 17.0f; + int selectedStep = 0; int playPos = -1; @@ -64,4 +66,8 @@ private: bool slideDragging = false; bool startedSlideActive = false; int slideLastStep = -1; + + bool accentDragging = false; + bool startedAccentActive = false; + int accentLastStep = -1; }; diff --git a/tests/TestHost.cpp b/tests/TestHost.cpp index 99be4d7..eda49e5 100644 --- a/tests/TestHost.cpp +++ b/tests/TestHost.cpp @@ -27,7 +27,8 @@ static bool patternsEqual (const monostep::StepSequencer& a, const monostep::Ste 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) + || std::fabs (sa.cents - sb.cents) > 0.001f + || sa.slide != sb.slide || sa.accent != sb.accent) return false; } @@ -63,7 +64,7 @@ static int testVst3Binary (const juce::File& pluginFile) // Part B: drive the actual processor source and render audio. static int testProcessorRendering() { - MonoStepAudioProcessor processor; + MonostepAudioProcessor processor; processor.prepareToPlay (44100.0, 512); const int totalSamples = (int) (44100.0 * 8.0); @@ -96,7 +97,7 @@ static int testProcessorRendering() } // Without a MIDI note the sequencer must stay silent. - MonoStepAudioProcessor idleProcessor; + MonostepAudioProcessor idleProcessor; idleProcessor.prepareToPlay (44100.0, 512); double idleSum = 0.0; @@ -129,6 +130,7 @@ static int testProcessorRendering() processor.setStepNote (0, 7); processor.setStepCents (0, 42.0f); processor.setStepSlide (1, true); + processor.setStepAccent (0, true); if (std::fabs (processor.getSequencer().step (0).cents - 42.0f) > 0.001f) { @@ -136,12 +138,97 @@ static int testProcessorRendering() return 1; } + // accent DSP: a uniform continuous pattern (all same note, gate held open) + // so no per-note retrigger masks whether accent follows the step. + *processor.getAPVTS().getRawParameterValue ("accent") = 1.0f; + *processor.getAPVTS().getRawParameterValue ("seqGateLen") = 1.0f; + + for (int i = 0; i < monostep::numSteps; ++i) + { + processor.setStepGate (i, true); + processor.setStepNote (i, 0); + processor.setStepSlide (i, false); + } + + const auto renderAccent = [&] (const std::function& accented) -> double + { + for (int i = 0; i < monostep::numSteps; ++i) + processor.setStepAccent (i, accented (i)); + + processor.reset(); + processor.prepareToPlay (44100.0, 512); + + juce::AudioBuffer accBlock (2, blockSize); + juce::MidiBuffer accMidi; + double sum = 0.0; + + for (int b = 0; b < numBlocks; ++b) + { + accBlock.clear(); + accMidi.clear(); + + if (b == 0) + accMidi.addEvent (juce::MidiMessage::noteOn (1, 60, 0.9f), 0); + + processor.processBlock (accBlock, accMidi); + + for (int c = 0; c < 2; ++c) + for (int i = 0; i < blockSize; ++i) + sum += (double) accBlock.getSample (c, i) * accBlock.getSample (c, i); + } + + return std::sqrt (sum / (double) (numBlocks * blockSize * 2)); + }; + + const double accRms = renderAccent ([] (int) { return true; }); + const double noAccRms = renderAccent ([] (int) { return false; }); + const double altRms = renderAccent ([] (int i) { return (i % 2) == 0; }); + + if (accRms < noAccRms * 1.05f) + { + std::cout << "FAILED: accent does not boost the step (acc=" << accRms + << " noacc=" << noAccRms << ")\n"; + return 1; + } + + if (altRms < noAccRms * 1.05f || altRms > accRms * 0.9f) + { + std::cout << "FAILED: accent does not track individual steps (all=" << accRms + << " alt=" << altRms << " noacc=" << noAccRms << ")\n"; + return 1; + } + + std::cout << "Accent boosts step volume OK (all=" << accRms + << " alternating=" << altRms << " none=" << noAccRms << ")\n"; + + // pattern shift rotates the steps + processor.setStepAccent (0, true); + processor.setStepNote (0, 7); + processor.setStepNote (1, 3); + processor.shiftPattern (1); + + if (processor.getSequencer().step (1).semitone != 7) + { + std::cout << "FAILED: shiftPattern did not rotate steps\n"; + return 1; + } + + processor.shiftPattern (-1); + + if (processor.getSequencer().step (0).semitone != 7 || ! processor.getSequencer().step (0).accent) + { + std::cout << "FAILED: shiftPattern did not rotate back\n"; + return 1; + } + + std::cout << "Pattern shift rotates steps OK\n"; + // state round trip through a fresh instance juce::MemoryBlock state; processor.getStateInformation (state); std::cout << "State bytes: " << (int) state.getSize() << "\n"; - MonoStepAudioProcessor processor2; + MonostepAudioProcessor processor2; processor2.prepareToPlay (44100.0, 512); processor2.setStateInformation (state.getData(), (int) state.getSize()); @@ -152,7 +239,8 @@ static int testProcessorRendering() 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"; + << " slide=" << a.slide << "/" << b.slide + << " accent=" << a.accent << "/" << b.accent << "\n"; } if (! patternsEqual (processor.getSequencer(), processor2.getSequencer())) @@ -218,7 +306,7 @@ int main (int argc, char** argv) { if (argc < 2) { - std::cout << "Usage: MonoStepTestHost [out.wav]\n"; + std::cout << "Usage: MonostepTestHost [out.wav]\n"; return 1; }