From 61bcef19aa013b1d2069c058fbff83dd2a9ca3c4 Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 8 Nov 2025 15:18:05 +0000 Subject: [PATCH] Add Wavetable Editor --- JuceLibraryCode/JucePluginDefines.h | 2 +- NeuralSynth.jucer | 15 + Source/PluginEditor.cpp | 30 +- Source/PluginEditor.h | 3 + Source/UI/CustomPresetWindow.cpp | 900 ++++++++++++++++++++++++++++ Source/UI/CustomPresetWindow.h | 10 + 6 files changed, 957 insertions(+), 3 deletions(-) create mode 100644 Source/UI/CustomPresetWindow.cpp create mode 100644 Source/UI/CustomPresetWindow.h diff --git a/JuceLibraryCode/JucePluginDefines.h b/JuceLibraryCode/JucePluginDefines.h index 83b6b02..e62ba92 100644 --- a/JuceLibraryCode/JucePluginDefines.h +++ b/JuceLibraryCode/JucePluginDefines.h @@ -155,7 +155,7 @@ #define JucePlugin_ARAFactoryID "com.SamediDimanche.NeuralSynth.factory" #endif #ifndef JucePlugin_ARADocumentArchiveID - #define JucePlugin_ARADocumentArchiveID "com.SamediDimanche.NeuralSynth.aradocumentarchive.0.0.1" + #define JucePlugin_ARADocumentArchiveID "com.SamediDimanche.NeuralSynth.aradocumentarchive.1.0.0" #endif #ifndef JucePlugin_ARACompatibleArchiveIDs #define JucePlugin_ARACompatibleArchiveIDs "" diff --git a/NeuralSynth.jucer b/NeuralSynth.jucer index f5d28d9..59e339a 100644 --- a/NeuralSynth.jucer +++ b/NeuralSynth.jucer @@ -8,6 +8,21 @@ pluginFormats="buildVST3"> + + + + + + + + + + + + + = baseId) { const int index = result - baseId; @@ -340,6 +350,22 @@ void NeuralSynthAudioProcessorEditor::showPresetMenu() }); } +void NeuralSynthAudioProcessorEditor::showCustomPresetWindow() +{ + constexpr int windowWidth = 420; + constexpr int windowHeight = 320; + + if (customPresetWindow == nullptr) + { + customPresetWindow = std::make_unique(); + customPresetWindow->setSize(windowWidth, windowHeight); + } + + customPresetWindow->centreAroundComponent(this, windowWidth, windowHeight); + customPresetWindow->setVisible(true); + customPresetWindow->toFront(true); +} + void NeuralSynthAudioProcessorEditor::updatePresetButtonLabel() { const auto& presets = audioProcessor.getFactoryPresets(); diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index 07043d5..1db3375 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -4,6 +4,7 @@ #include "PluginProcessor.h" #include "GraphComponent.h" #include "ScopeComponent.h" +#include "UI/CustomPresetWindow.h" //============================== ScopeSliderComponent ========================== // A generic panel: optional scope/graph + rotary sliders + labels. @@ -387,10 +388,12 @@ private: void updatePresetButtonLabel(); void showPresetMenu(); + void showCustomPresetWindow(); void handleLayerSelectionChanged(); juce::TextButton presetMenuButton; int lastPresetIndex { -1 }; + std::unique_ptr customPresetWindow; std::optional adsrComponent; // Amp Env std::optional chorusComponent; diff --git a/Source/UI/CustomPresetWindow.cpp b/Source/UI/CustomPresetWindow.cpp new file mode 100644 index 0000000..53a4654 --- /dev/null +++ b/Source/UI/CustomPresetWindow.cpp @@ -0,0 +1,900 @@ +#include "CustomPresetWindow.h" + +#include +#include +#include +#include +#include + +namespace +{ +constexpr int kBrowserColumns = 4; +constexpr int kBrowserRows = 10; + +class WaveThumbnail : public juce::Component +{ +public: + WaveThumbnail(const std::vector* tbl = nullptr) : table(tbl) {} + + void setTable(const std::vector* tbl) { table = tbl; repaint(); } + + void setHighlight(bool shouldHighlight) + { + if (highlight == shouldHighlight) + return; + highlight = shouldHighlight; + repaint(); + } + + void setOnClick(std::function handler) { onClick = std::move(handler); } + + void paint(juce::Graphics& g) override + { + auto bounds = getLocalBounds(); + + if (highlight) + { + g.setColour(juce::Colours::darkorange.withAlpha(0.35f)); + g.fillRect(bounds); + } + + g.setColour(juce::Colours::darkgrey); + g.drawRect(bounds); + + if (table == nullptr || table->empty()) + { + if (getName().isNotEmpty()) + { + g.setColour(juce::Colours::grey); + g.setFont(12.0f); + g.drawText(getName(), bounds.reduced(2), juce::Justification::centred); + } + return; + } + + g.setColour(juce::Colours::lime); + juce::Path path; + auto r = bounds; + path.startNewSubPath((float) r.getX(), (float) r.getCentreY()); + + const int n = (int) table->size(); + for (int i = 0; i < n; i += 32) + { + const float x = juce::jmap((float) i, 0.0f, (float) n, + (float) r.getX(), (float) r.getRight()); + const float y = juce::jmap((*table)[(size_t) i], -1.0f, 1.0f, + (float) r.getBottom(), (float) r.getY()); + path.lineTo(x, y); + } + + g.strokePath(path, juce::PathStrokeType(1.2f)); + + if (getName().isNotEmpty()) + { + g.setColour(juce::Colours::white.withAlpha(0.9f)); + g.setFont(12.0f); + g.drawText(getName(), bounds.reduced(2), juce::Justification::topRight); + } + } + + void mouseDown(const juce::MouseEvent& e) override + { + juce::ignoreUnused(e); + if (onClick != nullptr) + onClick(); + } + +private: + const std::vector* table { nullptr }; + bool highlight { false }; + std::function onClick; +}; + +class DrawWaveComponent : public juce::Component +{ +public: + DrawWaveComponent() + { + samples.assign(kTableSize, 0.0f); + } + + void clear() + { + std::fill(samples.begin(), samples.end(), 0.0f); + repaint(); + } + + void paint(juce::Graphics& g) override + { + auto r = getLocalBounds(); + + g.fillAll(juce::Colours::black.withAlpha(0.35f)); + g.setColour(juce::Colours::darkgrey); + g.drawRect(r); + + g.setColour(juce::Colours::white.withAlpha(0.08f)); + const int hLines = 8, vLines = 16; + for (int i = 1; i < hLines; ++i) + { + const float y = juce::jmap((float) i, 0.0f, (float) hLines, + (float) r.getY(), (float) r.getBottom()); + g.drawLine((float) r.getX(), y, (float) r.getRight(), y); + } + for (int i = 1; i < vLines; ++i) + { + const float x = juce::jmap((float) i, 0.0f, (float) vLines, + (float) r.getX(), (float) r.getRight()); + g.drawLine(x, (float) r.getY(), x, (float) r.getBottom()); + } + + g.setColour(juce::Colours::white.withAlpha(0.15f)); + g.drawLine((float) r.getX(), (float) r.getCentreY(), + (float) r.getRight(), (float) r.getCentreY(), 1.2f); + + if (samples.empty()) + return; + + g.setColour(juce::Colours::deepskyblue); + juce::Path p; + const int n = (int) samples.size(); + p.startNewSubPath((float) r.getX(), + juce::jmap(samples[0], -1.0f, 1.0f, + (float) r.getBottom(), (float) r.getY())); + + const int step = 4; + for (int i = step; i < n; i += step) + { + const float x = juce::jmap((float) i, 0.0f, (float) n - 1, + (float) r.getX(), (float) r.getRight()); + const float y = juce::jmap(samples[(size_t) i], -1.0f, 1.0f, + (float) r.getBottom(), (float) r.getY()); + p.lineTo(x, y); + } + + g.strokePath(p, juce::PathStrokeType(2.0f)); + } + + void mouseDown(const juce::MouseEvent& e) override + { + lastDrawIndex = -1; + drawAt(e); + } + + void mouseDrag(const juce::MouseEvent& e) override + { + drawAt(e); + } + + const std::vector& getTable() const { return samples; } + +private: + static constexpr int kTableSize = 2048; + + void drawAt(const juce::MouseEvent& e) + { + auto r = getLocalBounds(); + + const float xNorm = juce::jlimit(0.0f, 1.0f, + (e.position.x - (float) r.getX()) / (float) r.getWidth()); + const float yNorm = juce::jlimit(-1.0f, 1.0f, + juce::jmap(e.position.y, (float) r.getBottom(), (float) r.getY(), -1.0f, 1.0f)); + + const int n = (int) samples.size(); + const int idx = (int) juce::jlimit(0.0f, (float) (n - 1), xNorm * (float) n); + + if (lastDrawIndex >= 0 && lastDrawIndex != idx) + { + const int a = juce::jmin(lastDrawIndex, idx); + const int b = juce::jmax(lastDrawIndex, idx); + const float v0 = samples[(size_t) lastDrawIndex]; + const float v1 = yNorm; + for (int i = a; i <= b; ++i) + { + const float t = (b == a ? 1.0f : (float) (i - a) / (float) (b - a)); + setSampleAt(i, juce::jmap(t, v0, v1)); + } + } + else + { + setSampleAt(idx, yNorm); + } + + lastDrawIndex = idx; + repaint(); + } + + void setSampleAt(int idx, float value) + { + if ((size_t) idx < samples.size()) + samples[(size_t) idx] = juce::jlimit(-1.0f, 1.0f, value); + } + + std::vector samples; + int lastDrawIndex { -1 }; +}; + +class MetalKnobLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height, + float sliderPosProportional, float rotaryStartAngle, + float rotaryEndAngle, juce::Slider& slider) override + { + juce::ignoreUnused(slider); + + const float radius = juce::jmin(width, height) * 0.45f; + const float cx = (float) x + (float) width * 0.5f; + const float cy = (float) y + (float) height * 0.5f; + + g.setColour(juce::Colour(0xff303030)); + g.fillEllipse(cx - radius, cy - radius, radius * 2.0f, radius * 2.0f); + + juce::ColourGradient grad(juce::Colours::darkgrey, cx, cy - radius, + juce::Colours::black, cx, cy + radius, true); + g.setGradientFill(grad); + g.fillEllipse(cx - radius * 0.95f, cy - radius * 0.95f, radius * 1.9f, radius * 1.9f); + + g.setColour(juce::Colours::lightgrey.withAlpha(0.8f)); + const int tickCount = 8; + for (int i = 0; i < tickCount; ++i) + { + const float a = juce::MathConstants::twoPi * (float) i / (float) tickCount; + const float r1 = radius * 1.08f; + const float r2 = radius * 1.25f; + g.drawLine(cx + r1 * std::cos(a), cy + r1 * std::sin(a), + cx + r2 * std::cos(a), cy + r2 * std::sin(a), 1.6f); + } + + const float angle = rotaryStartAngle + sliderPosProportional * (rotaryEndAngle - rotaryStartAngle); + juce::Path needle; + const float rInner = radius * 0.18f; + const float rOuter = radius * 0.80f; + needle.addRoundedRectangle(-2.4f, -rOuter, 4.8f, rOuter - rInner, 1.8f); + auto transform = juce::AffineTransform::rotation(angle).translated(cx, cy); + g.setColour(juce::Colours::whitesmoke); + g.fillPath(needle, transform); + } +}; + +class FlatButtonLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + void drawButtonBackground(juce::Graphics& g, juce::Button& b, const juce::Colour& bg, + bool isHighlighted, bool isDown) override + { + auto r = b.getLocalBounds().toFloat(); + auto base = bg; + if (isDown) base = base.darker(0.15f); + else if (isHighlighted) base = base.brighter(0.08f); + + g.setColour(base); + g.fillRoundedRectangle(r, 4.0f); + g.setColour(juce::Colours::black.withAlpha(0.5f)); + g.drawRoundedRectangle(r.reduced(0.5f), 4.0f, 1.0f); + } +}; + +struct DummyPreset +{ + juce::String name; +}; + +struct DummyPresetCategory +{ + juce::String name; + std::vector presets; +}; + +const std::vector& getDemoPresetCategories() +{ + static const std::vector categories { + { "Pads", { { "Nebula Drift" }, { "Glass Sky" } } }, + { "Bass", { { "Driver" }, { "Submerge" } } }, + { "Leads", { { "Starlit" }, { "Binary Pulse" } } } + }; + return categories; +} + +class DummyWavetableSynthAudioProcessor +{ +public: + static constexpr int kTableSize = 2048; + + DummyWavetableSynthAudioProcessor() + { + addWave("Sine", [](float phase) { return std::sin(phase); }); + addWave("Square", [](float phase) { return std::signbit(std::sin(phase)) ? -1.0f : 1.0f; }); + addWave("Saw", [](float phase) { return 2.0f * (phase / juce::MathConstants::twoPi) - 1.0f; }); + addWave("Triangle", [](float phase) + { + const float v = 2.0f * std::fabs(2.0f * (phase / juce::MathConstants::twoPi) - 1.0f) - 1.0f; + return juce::jlimit(-1.0f, 1.0f, -v); + }); + } + + int getWaveTableCount() const { return (int) tables.size(); } + + const std::vector* getPreviewTablePtr(int index) const + { + if (juce::isPositiveAndBelow(index, (int) tables.size())) + return &tables[(size_t) index].samples; + return nullptr; + } + + int addOrReplaceUserWavetable(const std::vector& newSamples) + { + if (newSamples.empty()) + return -1; + + Table table; + table.name = "User " + juce::String(tables.size() + 1); + table.samples = newSamples; + table.samples.resize(kTableSize, 0.0f); + tables.push_back(std::move(table)); + return (int) tables.size() - 1; + } + + bool isMorphLoopActive() const { return morphLoopActive; } + void setMorphLoopActive(bool active) { morphLoopActive = active; } + + float getMorphDisplayValue() const { return morphDisplayValue; } + void setMorphDisplayValue(float value) { morphDisplayValue = value; } + + void notifyPresetLoaded() {} + +private: + struct Table + { + juce::String name; + std::vector samples; + }; + + void addWave(const juce::String& name, const std::function& generator) + { + Table table; + table.name = name; + table.samples.resize(kTableSize); + for (int i = 0; i < kTableSize; ++i) + { + const float phase = juce::MathConstants::twoPi * (float) i / (float) kTableSize; + table.samples[(size_t) i] = juce::jlimit(-1.0f, 1.0f, generator(phase)); + } + tables.push_back(std::move(table)); + } + + std::vector tables; + bool morphLoopActive { false }; + float morphDisplayValue { 0.0f }; +}; + +class ExampleUIPanel : public juce::Component, + private juce::Timer +{ +public: + ExampleUIPanel() + { + setSize(1100, 720); + + morphSlider.setSliderStyle(juce::Slider::LinearHorizontal); + morphSlider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + addAndMakeVisible(morphSlider); + + morphLoopToggle.setButtonText("Loop Morph"); + morphLoopToggle.onClick = [this] + { + audioProcessor.setMorphLoopActive(morphLoopToggle.getToggleState()); + }; + addAndMakeVisible(morphLoopToggle); + + morphLoopMode.addItemList(juce::StringArray { "Forward", "Ping-Pong", "Half Trip" }, 1); + morphLoopMode.setJustificationType(juce::Justification::centred); + morphLoopMode.setSelectedItemIndex(0); + addAndMakeVisible(morphLoopMode); + + configureKnob(master); + master.setLookAndFeel(&metalKnobLNF); + addAndMakeVisible(master); + addAndMakeVisible(lblMaster); + labelAbove(lblMaster, "Master"); + + auto configureDefaultKnob = [this](juce::Slider& slider) + { + configureKnob(slider); + slider.setLookAndFeel(&metalKnobLNF); + addAndMakeVisible(slider); + }; + + for (auto* slider : { &cutoffSlider, &attack, &decay, &sustain, &release, + &lfoRate, &lfoDepth, &fenvA, &fenvD, &fenvS, &fenvR, &fenvAmt, + &chRate, &chDepth, &chDelay, &chFb, &chMix, + &rvRoom, &rvDamp, &rvWidth, &rvWet }) + { + configureDefaultKnob(*slider); + } + + chorusOn.setButtonText("Chorus"); + reverbOn.setButtonText("Reverb"); + osc2Mute.setButtonText("Deactivate Osc2"); + addAndMakeVisible(chorusOn); + addAndMakeVisible(reverbOn); + addAndMakeVisible(osc2Mute); + + addToBrowser.setButtonText("Add to Browser"); + clearDraw.setButtonText("Clear"); + presetButton.setButtonText(selectedPresetLabel); + for (auto* btn : { &addToBrowser, &clearDraw, &presetButton }) + { + btn->setLookAndFeel(&flatBtnLNF); + addAndMakeVisible(*btn); + } + + addToBrowser.onClick = [this] + { + const int slotIndex = audioProcessor.addOrReplaceUserWavetable(userDraw.getTable()); + if (slotIndex >= 0) + { + slotIndices[(size_t) activeSlot] = slotIndex; + updateSlotThumbnails(); + repaint(); + } + }; + clearDraw.onClick = [this] { userDraw.clear(); }; + presetButton.onClick = [this] { showPresetMenu(); }; + + addAndMakeVisible(wavetableTabs); + wavetableTabs.addTab("Editor", juce::Colours::transparentBlack, &editorTab, false); + wavetableTabs.addTab("Library", juce::Colours::transparentBlack, &libraryTab, false); + wavetableTabs.setColour(juce::TabbedComponent::outlineColourId, juce::Colours::darkgrey); + + editorTab.addAndMakeVisible(lblDrawWave); + editorTab.addAndMakeVisible(userDraw); + lblDrawWave.setText("DRAW WAVE", juce::dontSendNotification); + lblDrawWave.setColour(juce::Label::textColourId, juce::Colours::white); + lblDrawWave.setJustificationType(juce::Justification::left); + + for (auto* box : { &slotABox, &slotBBox, &slotCBox }) + libraryTab.addAndMakeVisible(*box); + + slotABox.setName("A"); + slotBBox.setName("B"); + slotCBox.setName("C"); + slotABox.setOnClick([this]{ setActiveSlot(0); }); + slotBBox.setOnClick([this]{ setActiveSlot(1); }); + slotCBox.setOnClick([this]{ setActiveSlot(2); }); + setActiveSlot(0); + + updateSlotThumbnails(); + + startTimerHz(30); + } + + ~ExampleUIPanel() override + { + stopTimer(); + master.setLookAndFeel(nullptr); + for (auto* slider : { &cutoffSlider, &attack, &decay, &sustain, &release, + &lfoRate, &lfoDepth, &fenvA, &fenvD, &fenvS, &fenvR, &fenvAmt, + &chRate, &chDepth, &chDelay, &chFb, &chMix, + &rvRoom, &rvDamp, &rvWidth, &rvWet }) + { + slider->setLookAndFeel(nullptr); + } + for (auto* btn : { &addToBrowser, &clearDraw, &presetButton }) + btn->setLookAndFeel(nullptr); + } + + void paint(juce::Graphics& g) override + { + g.fillAll(juce::Colours::black); + + auto top = getTopPanelBounds(); + { + juce::Colour c1 = juce::Colour::fromRGB(105,105,110); + juce::Colour c2 = juce::Colour::fromRGB(75,75,80); + g.setGradientFill(juce::ColourGradient(c1, (float) top.getX(), (float) top.getY(), + c2, (float) top.getX(), (float) top.getBottom(), false)); + g.fillRect(top); + g.setColour(juce::Colours::white.withAlpha(0.05f)); + for (int y = top.getY(); y < top.getBottom(); y += 3) + g.drawLine((float) top.getX(), (float) y, (float) top.getRight(), (float) y, 1.0f); + g.setColour(juce::Colours::black.withAlpha(0.6f)); + g.drawRect(top, 2); + + g.setColour(juce::Colours::white); + g.setFont(18.0f); + g.drawText("RTWAVE - WAVETABLE SYNTH", 12, top.getY() + 14, 400, 22, juce::Justification::left); + } + + auto bottom = getBottomPanelBounds(); + { + juce::Colour c1 = juce::Colour::fromRGB(110,110,115); + juce::Colour c2 = juce::Colour::fromRGB(70,70,75); + g.setGradientFill(juce::ColourGradient(c1, (float) bottom.getX(), (float) bottom.getY(), + c2, (float) bottom.getX(), (float) bottom.getBottom(), false)); + g.fillRect(bottom); + g.setColour(juce::Colours::white.withAlpha(0.05f)); + for (int y = bottom.getY(); y < bottom.getBottom(); y += 3) + g.drawLine((float) bottom.getX(), (float) y, (float) bottom.getRight(), (float) y, 1.0f); + g.setColour(juce::Colours::black.withAlpha(0.6f)); + g.drawRect(bottom, 2); + } + + const int blackTop = top.getBottom(); + const int blackBottom = bottom.getY(); + const int leftWidth = getWidth() / 2 - 20; + const int rightX = leftWidth + 30; + + auto browser = juce::Rectangle(10, blackTop + 8, leftWidth - 20, blackBottom - blackTop - 16); + g.setColour(juce::Colours::grey); + g.drawRect(browser); + + const int cellW = browser.getWidth() / kBrowserColumns; + const int cellH = browser.getHeight() / kBrowserRows; + browserCells.clear(); + browserCells.reserve(kBrowserColumns * kBrowserRows); + + const int waveCount = audioProcessor.getWaveTableCount(); + + for (int r = 0; r < kBrowserRows; ++r) + for (int c = 0; c < kBrowserColumns; ++c) + { + const int idx = r * kBrowserColumns + c; + auto cell = juce::Rectangle(browser.getX() + c * cellW, + browser.getY() + r * cellH, + cellW, cellH); + browserCells.push_back(cell); + + g.setColour(juce::Colours::darkgrey); + g.drawRect(cell); + + if (idx < waveCount) + { + if (const auto* tbl = audioProcessor.getPreviewTablePtr(idx)) + { + g.setColour(juce::Colours::lime); + juce::Path p; + p.startNewSubPath((float) cell.getX(), (float) cell.getCentreY()); + const int n = (int) tbl->size(); + for (int i = 0; i < n; i += 32) + { + const float x = juce::jmap((float) i, 0.0f, (float) n, + (float) cell.getX(), (float) cell.getRight()); + const float y = juce::jmap((*tbl)[(size_t) i], -1.0f, 1.0f, + (float) cell.getBottom(), (float) cell.getY()); + p.lineTo(x, y); + } + g.strokePath(p, juce::PathStrokeType(1.0f)); + } + } + else + { + g.setColour(juce::Colours::darkgrey); + g.drawLine((float) cell.getX(), (float) cell.getY(), (float) cell.getRight(), (float) cell.getBottom(), 0.5f); + g.drawLine((float) cell.getRight(), (float) cell.getY(), (float) cell.getX(), (float) cell.getBottom(), 0.5f); + } + } + + g.setColour(juce::Colours::darkred); + g.fillRect(juce::Rectangle(rightX, blackTop + 6, getWidth() - rightX - 30, 6)); + g.setColour(juce::Colours::white); + g.setFont(18.0f); + g.drawText("MORPH", rightX, blackTop + 14, getWidth() - rightX - 30, 20, juce::Justification::centred); + } + + void resized() override + { + auto top = getTopPanelBounds(); + auto bottom = getBottomPanelBounds(); + + const int blackTop = top.getBottom(); + const int blackBottom = bottom.getY(); + const int leftWidth = getWidth() / 2 - 20; + const int rightX = leftWidth + 30; + + morphSlider.setBounds(rightX, blackTop + 16, getWidth() - rightX - 30, 18); + morphLoopToggle.setBounds(rightX, morphSlider.getBottom() + 10, 120, 22); + morphLoopMode.setBounds(morphLoopToggle.getRight() + 8, morphSlider.getBottom() + 6, 150, 26); + + presetButton.setBounds(getWidth() - 130 - 10, top.getY() + 24, 130, 28); + + const int padTop = morphLoopMode.getBottom() + 10; + auto tabBounds = juce::Rectangle(rightX, padTop, getWidth() - rightX - 16, blackBottom - padTop - 70); + wavetableTabs.setBounds(tabBounds); + + auto editorBounds = editorTab.getLocalBounds().reduced(12); + auto labelBounds = editorBounds.removeFromTop(20); + lblDrawWave.setBounds(labelBounds.getX(), labelBounds.getY(), 140, labelBounds.getHeight()); + editorBounds.removeFromTop(6); + userDraw.setBounds(editorBounds); + + auto libraryBounds = libraryTab.getLocalBounds().reduced(12); + const int slotSpacing = 12; + const int slotHeight = 32; + int slotWidth = (libraryBounds.getWidth() - slotSpacing * 2) / 3; + slotWidth = juce::jmax(90, slotWidth); + const bool fitsHorizontally = (slotWidth * 3 + slotSpacing * 2) <= libraryBounds.getWidth(); + + if (fitsHorizontally) + { + const int slotY = libraryBounds.getY(); + int slotX = libraryBounds.getX(); + slotABox.setBounds(slotX, slotY, slotWidth, slotHeight); + slotX += slotWidth + slotSpacing; + slotBBox.setBounds(slotX, slotY, slotWidth, slotHeight); + slotX += slotWidth + slotSpacing; + slotCBox.setBounds(slotX, slotY, slotWidth, slotHeight); + } + else + { + int slotY = libraryBounds.getY(); + const int slotX = libraryBounds.getX(); + slotWidth = libraryBounds.getWidth(); + for (auto* box : { &slotABox, &slotBBox, &slotCBox }) + { + box->setBounds(slotX, slotY, slotWidth, slotHeight); + slotY += slotHeight + slotSpacing; + } + } + + const int buttonRowY = wavetableTabs.getBottom() + 8; + addToBrowser.setBounds(wavetableTabs.getX() + 220, buttonRowY, 150, 28); + clearDraw.setBounds(addToBrowser.getRight() + 12, buttonRowY, 150, 28); + + const int togglesY = bottom.getY() - 36; + reverbOn.setBounds(getWidth() - 280, togglesY, 120, 24); + osc2Mute .setBounds(getWidth() - 140, togglesY, 140, 24); + chorusOn.setBounds(wavetableTabs.getX(), buttonRowY, 90, 20); + + const int masterW = 82; + lblMaster.setBounds(getWidth() - masterW - 150, top.getY() + 4, 80, 16); + master .setBounds(getWidth() - masterW - 150, top.getY() + 16, masterW, masterW); + + const int left = bottom.getX() + 18; + const int topY = bottom.getY() + 28; + const int stepX = 96; + const int stepY = 108; + const int knobW = 72, knobH = 72; + + auto place = [&](int col, int row, juce::Label& label, juce::Slider& slider, const char* text) + { + const int x = left + col * stepX; + const int y = topY + row * stepY; + labelAbove(label, text); + label.setBounds(x, y - 14, 88, 14); + slider.setBounds(x, y, knobW, knobH); + }; + + place (0,0, lblCutoff, cutoffSlider, "Cutoff"); + place (1,0, lblAttack, attack, "Attack"); + place (2,0, lblDecay, decay, "Decay"); + place (3,0, lblSustain, sustain, "Sustain"); + place (4,0, lblRelease, release, "Release"); + place (5,0, lblLfoRate, lfoRate, "LFO Rate"); + place (6,0, lblLfoDepth,lfoDepth, "LFO Depth"); + place (7,0, lblFenvA, fenvA, "FEnv A"); + place (8,0, lblFenvD, fenvD, "FEnv D"); + place (9,0, lblFenvS, fenvS, "FEnv S"); + + place (0,1, lblFenvR, fenvR, "FEnv R"); + place (1,1, lblFenvAmt, fenvAmt, "FEnv Amt"); + place (2,1, lblChRate, chRate, "Ch Rate"); + place (3,1, lblChDepth, chDepth, "Ch Depth"); + place (4,1, lblChDelay, chDelay, "Ch Delay"); + place (5,1, lblChFb, chFb, "Ch FB"); + place (6,1, lblChMix, chMix, "Ch Mix"); + place (7,1, lblRvRoom, rvRoom, "Rev Room"); + place (8,1, lblRvDamp, rvDamp, "Rev Damp"); + place (9,1, lblRvWidth, rvWidth, "Rev Width"); + place (10,1,lblRvWet, rvWet, "Rev Wet"); + } + + void mouseDown(const juce::MouseEvent& e) override + { + handleBrowserClick(e.getPosition()); + } + +private: + juce::Rectangle getTopPanelBounds() const { return { 0, 0, getWidth(), 80 }; } + juce::Rectangle getBottomPanelBounds() const + { + const int panelHeight = 220; + return { 0, getHeight() - panelHeight, getWidth(), panelHeight }; + } + + void configureKnob(juce::Slider& slider) + { + slider.setRange(0.0, 1.0, 0.0); + slider.setSliderStyle(juce::Slider::Rotary); + slider.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + const float start = juce::MathConstants::pi * 1.25f; + const float end = start + juce::MathConstants::pi * 1.5f; + slider.setRotaryParameters(start, end, true); + } + + static void labelAbove(juce::Label& label, const juce::String& text) + { + label.setText(text, juce::dontSendNotification); + label.setJustificationType(juce::Justification::centred); + label.setColour(juce::Label::textColourId, juce::Colours::black); + } + + void handleBrowserClick(juce::Point pos) + { + const int waveCount = audioProcessor.getWaveTableCount(); + for (size_t i = 0; i < browserCells.size(); ++i) + { + if (browserCells[i].contains(pos)) + { + if ((int) i >= waveCount) + return; + + slotIndices[(size_t) activeSlot] = (int) i; + updateSlotThumbnails(); + repaint(); + break; + } + } + } + + void setActiveSlot(int slot) + { + activeSlot = juce::jlimit(0, 2, slot); + slotABox.setHighlight(activeSlot == 0); + slotBBox.setHighlight(activeSlot == 1); + slotCBox.setHighlight(activeSlot == 2); + } + + void updateSlotThumbnails() + { + const int waveCount = audioProcessor.getWaveTableCount(); + if (waveCount <= 0) + { + slotABox.setTable(nullptr); + slotBBox.setTable(nullptr); + slotCBox.setTable(nullptr); + return; + } + + const int maxIndex = waveCount - 1; + slotIndices[0] = juce::jlimit(0, maxIndex, slotIndices[0]); + slotIndices[1] = juce::jlimit(0, maxIndex, slotIndices[1]); + slotIndices[2] = juce::jlimit(0, maxIndex, slotIndices[2]); + + slotABox.setTable(audioProcessor.getPreviewTablePtr(slotIndices[0])); + slotBBox.setTable(audioProcessor.getPreviewTablePtr(slotIndices[1])); + slotCBox.setTable(audioProcessor.getPreviewTablePtr(slotIndices[2])); + } + + void showPresetMenu() + { + juce::PopupMenu menu; + const auto& categories = getDemoPresetCategories(); + int idBase = 1000; + + for (size_t ci = 0; ci < categories.size(); ++ci) + { + juce::PopupMenu sub; + const auto& cat = categories[ci]; + for (size_t pi = 0; pi < cat.presets.size(); ++pi) + sub.addItem(idBase + (int) (ci * 100 + pi), cat.presets[pi].name); + menu.addSubMenu(cat.name, sub); + } + + menu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&presetButton), + [this, idBase](int result) + { + if (result <= 0) + return; + + const int ci = (result - idBase) / 100; + const int pi = (result - idBase) % 100; + const auto& categories = getDemoPresetCategories(); + if (juce::isPositiveAndBelow(ci, (int) categories.size())) + { + const auto& cat = categories[(size_t) ci]; + if (juce::isPositiveAndBelow(pi, (int) cat.presets.size())) + { + selectedPresetLabel = cat.name + " / " + cat.presets[(size_t) pi].name; + presetButton.setButtonText(selectedPresetLabel); + audioProcessor.notifyPresetLoaded(); + setActiveSlot(0); + repaint(); + } + } + }); + } + + void timerCallback() override + { + updateSlotThumbnails(); + + const bool loopActive = morphLoopToggle.getToggleState() || audioProcessor.isMorphLoopActive(); + + if (! morphSlider.isMouseButtonDown() && loopActive) + { + const double now = juce::Time::getMillisecondCounterHiRes(); + const double dt = (now - lastTimerMs) * 0.001; + lastTimerMs = now; + + const int mode = morphLoopMode.getSelectedItemIndex(); + const float speed = 0.25f; + localMorphPhase = std::fmod(localMorphPhase + speed * (float) dt, 1.0f); + float value = localMorphPhase; + + switch (mode) + { + case 1: + value = (localMorphPhase < 0.5f) + ? (localMorphPhase * 2.0f) + : (1.0f - (localMorphPhase - 0.5f) * 2.0f); + break; + case 2: + value = 0.5f * localMorphPhase; + break; + default: + break; + } + + morphSlider.setValue(value, juce::dontSendNotification); + audioProcessor.setMorphDisplayValue(value); + } + + setActiveSlot(activeSlot); + repaint(); + } + + DummyWavetableSynthAudioProcessor audioProcessor; + MetalKnobLookAndFeel metalKnobLNF; + FlatButtonLookAndFeel flatBtnLNF; + + juce::Slider morphSlider; + juce::ToggleButton morphLoopToggle; + juce::ComboBox morphLoopMode; + + juce::Slider master; + juce::Label lblMaster; + + juce::Slider cutoffSlider, attack, decay, sustain, release; + juce::Slider lfoRate, lfoDepth, fenvA, fenvD, fenvS, fenvR, fenvAmt; + juce::Slider chRate, chDepth, chDelay, chFb, chMix; + juce::Slider rvRoom, rvDamp, rvWidth, rvWet; + + juce::ToggleButton chorusOn, reverbOn, osc2Mute; + + juce::TabbedComponent wavetableTabs { juce::TabbedButtonBar::TabsAtTop }; + juce::Component editorTab, libraryTab; + + WaveThumbnail slotABox, slotBBox, slotCBox; + DrawWaveComponent userDraw; + juce::TextButton addToBrowser, clearDraw, presetButton; + juce::Label lblDrawWave; + + juce::Label lblCutoff, lblAttack, lblDecay, lblSustain, lblRelease; + juce::Label lblLfoRate, lblLfoDepth, lblFenvA, lblFenvD, lblFenvS, lblFenvR, lblFenvAmt; + juce::Label lblChRate, lblChDepth, lblChDelay, lblChFb, lblChMix; + juce::Label lblRvRoom, lblRvDamp, lblRvWidth, lblRvWet; + + std::vector> browserCells; + std::array slotIndices { 0, 1, 2 }; + int activeSlot { 0 }; + juce::String selectedPresetLabel { "Presets" }; + double lastTimerMs { juce::Time::getMillisecondCounterHiRes() }; + float localMorphPhase { 0.0f }; +}; +} // namespace + +CustomPresetWindow::CustomPresetWindow() + : juce::DocumentWindow("Custom Preset", + juce::Colours::darkgrey, + juce::DocumentWindow::closeButton) +{ + setUsingNativeTitleBar(true); + setResizable(true, false); + setContentOwned(new ExampleUIPanel(), true); + setSize(5000, 5000); +} + +void CustomPresetWindow::closeButtonPressed() +{ + setVisible(false); +} diff --git a/Source/UI/CustomPresetWindow.h b/Source/UI/CustomPresetWindow.h new file mode 100644 index 0000000..9c3d96b --- /dev/null +++ b/Source/UI/CustomPresetWindow.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +class CustomPresetWindow : public juce::DocumentWindow +{ +public: + CustomPresetWindow(); + void closeButtonPressed() override; +};