diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ea9fc7..356777d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y ninja-build pkg-config \ + sudo apt-get install -y ninja-build pkg-config xvfb \ libasound2-dev libx11-dev libxext-dev libxinerama-dev libxrandr-dev \ libxcursor-dev libgl1-mesa-dev libfreetype6-dev libfontconfig1-dev @@ -84,8 +84,30 @@ jobs: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release fi - - name: Build VST3 and CLAP - run: cmake --build build --config Release --target Suppressor_VST3 Suppressor_CLAP + - name: Build VST3, CLAP, and UI tests + run: cmake --build build --config Release --target Suppressor_VST3 Suppressor_CLAP SuppressorUITests + + - name: Run UI tests and capture declared scales + shell: bash + run: | + mkdir -p ui-captures + if [ "${{ runner.os }}" = "Windows" ]; then + export SUPPRESSOR_UI_CAPTURE_DIR="$(pwd -W)/ui-captures" + else + export SUPPRESSOR_UI_CAPTURE_DIR="$PWD/ui-captures" + fi + if [ "${{ runner.os }}" = "Linux" ]; then + xvfb-run -a ctest --test-dir build --output-on-failure -C Release -R suppressor-ui-unit + else + ctest --test-dir build --output-on-failure -C Release -R suppressor-ui-unit + fi + find ui-captures -type f -name "*.png" -print + + - uses: actions/upload-artifact@v4 + with: + name: ui-evidence-${{ matrix.os }} + path: ui-captures/*.png + retention-days: 14 - name: Stage artifacts shell: bash @@ -93,7 +115,9 @@ jobs: set -e mkdir -p staging find build -type d -name "*.vst3" -exec cp -R {} staging/ \; - find build -type f -name "*.clap" -exec cp {} staging/ \; + find build -name "*.clap" -exec cp -R {} staging/ \; + find staging -maxdepth 1 -name "*.vst3" -print -quit | grep -q . + find staging -maxdepth 1 -name "*.clap" -print -quit | grep -q . ls -la staging/ - uses: actions/upload-artifact@v4 @@ -121,7 +145,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y xvfb libasound2 libx11-6 libxext6 libxinerama1 \ + sudo apt-get install -y xvfb libasound2t64 libx11-6 libxext6 libxinerama1 \ libxrandr2 libxcursor1 libgl1 libfreetype6 libfontconfig1 - name: Download pluginval v1.0.4 @@ -166,9 +190,9 @@ jobs: echo "vst3: ${VST3}" chmod +x "${PLUGINVAL}" || true if [ "${{ runner.os }}" = "Linux" ]; then - xvfb-run -a "${PLUGINVAL}" --strictness-level 5 --timeout-ms 300000 --validate "${VST3}" + xvfb-run -a "${PLUGINVAL}" --strictness-level 10 --timeout-ms 300000 --validate "${VST3}" else - "${PLUGINVAL}" --strictness-level 5 --timeout-ms 300000 --validate "${VST3}" + "${PLUGINVAL}" --strictness-level 10 --timeout-ms 300000 --validate "${VST3}" fi - name: Validate CLAP with clap-validator @@ -207,7 +231,7 @@ jobs: short=${osname#suppressor-} (cd "$osdir" && \ for d in *.vst3; do [ -e "$d" ] && zip -qr "../suppressor-${short}-vst3.zip" "$d"; done && \ - for f in *.clap; do [ -e "$f" ] && zip -q "../suppressor-${short}-clap.zip" "$f"; done) + for f in *.clap; do [ -e "$f" ] && zip -qr "../suppressor-${short}-clap.zip" "$f"; done) done ls -la *.zip diff --git a/README.md b/README.md index bdfc35a..5dd4f69 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ body alone. Formats: **VST3** and **CLAP** (Windows, macOS, Linux). [![CI](https://github.com/user1303836/suppressor/actions/workflows/ci.yml/badge.svg)](https://github.com/user1303836/suppressor/actions/workflows/ci.yml) +

+ Suppressor plugin interface +

+ ## What it does The core is a causal, sample-domain **dynamic low-pass**: diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 59fb564..2602190 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -1,3 +1,10 @@ +juce_add_binary_data(SuppressorAssets + NAMESPACE SuppressorAssets + HEADER_NAME SuppressorAssets.h + SOURCES + ../assets/fonts/BarlowCondensed-Regular.ttf + ../assets/fonts/BarlowCondensed-SemiBold.ttf) + juce_add_plugin(Suppressor VERSION 0.1.0 COMPANY_NAME "Suppressor Audio" @@ -12,13 +19,14 @@ juce_add_plugin(Suppressor NEEDS_MIDI_INPUT FALSE NEEDS_MIDI_OUTPUT FALSE IS_MIDI_EFFECT FALSE - EDITOR_WANTS_KEYBOARD_FOCUS FALSE + EDITOR_WANTS_KEYBOARD_FOCUS TRUE COPY_PLUGIN_AFTER_BUILD FALSE VST3_CATEGORIES Fx Dynamics) target_sources(Suppressor PRIVATE PluginProcessor.cpp - PluginEditor.cpp) + PluginEditor.cpp + PluginLookAndFeel.cpp) target_compile_definitions(Suppressor PUBLIC JUCE_WEB_BROWSER=0 @@ -28,6 +36,7 @@ target_compile_definitions(Suppressor PUBLIC JUCE_STRICT_REFCOUNTEDPOINTER=1) target_link_libraries(Suppressor PRIVATE + SuppressorAssets suppressor-dsp juce::juce_audio_utils juce::juce_recommended_config_flags @@ -42,3 +51,26 @@ if (SUPPRESSOR_ENABLE_CLAP) CLAP_PROCESS_EVENTS_RESOLUTION_SAMPLES 16 CLAP_ALWAYS_JUCE_BRIDGE TRUE) endif() + +if (SUPPRESSOR_BUILD_TESTS) + add_executable(SuppressorUITests) + + target_sources(SuppressorUITests PRIVATE + ../Tests/test_ui.cpp) + + target_include_directories(SuppressorUITests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}) + + target_link_libraries(SuppressorUITests PRIVATE + Suppressor + doctest::doctest_with_main + juce::juce_audio_utils + juce::juce_recommended_config_flags + juce::juce_recommended_warning_flags) + + target_compile_definitions(SuppressorUITests PRIVATE + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0) + + add_test(NAME suppressor-ui-unit COMMAND SuppressorUITests) +endif() diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 6df9cd2..ee04536 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -1,246 +1,668 @@ #include "PluginEditor.h" -namespace -{ -const juce::Colour kBg { 0xff17181c }; -const juce::Colour kPanel { 0xff22242a }; -const juce::Colour kAccent { 0xff4fc3f7 }; -const juce::Colour kText { 0xffe0e0e0 }; -constexpr int kKnobW = 78, kKnobH = 84, kLabelH = 16; -} // namespace - -SuppressorEditor::SuppressorEditor (SuppressorProcessor& p) - : juce::AudioProcessorEditor (&p), proc (p) -{ - auto& look = getLookAndFeel(); - look.setColour (juce::Slider::rotarySliderFillColourId, kAccent); - look.setColour (juce::Slider::rotarySliderOutlineColourId, juce::Colour (0xff3a3d45)); - look.setColour (juce::Label::textColourId, kText); - look.setColour (juce::ComboBox::backgroundColourId, kPanel); - look.setColour (juce::ComboBox::textColourId, kText); - look.setColour (juce::ToggleButton::textColourId, kText); - look.setColour (juce::GroupComponent::outlineColourId, juce::Colour (0xff3a3d45)); - look.setColour (juce::GroupComponent::textColourId, kAccent); - - auto& core = addGroup ("Core", { 10, 10, 260, 200 }); - addKnob ("strength", "Strength", core); - addKnob ("threshold", "Threshold", core); - addKnob ("release", "Release", core); - - auto& gate = addGroup ("Gate", { 280, 10, 300, 200 }); - addCombo ("gateMode", "Mode", gate); - addKnob ("depth", "Depth", gate); - addKnob ("hysteresis", "Hysteresis", gate); - addKnob ("hold", "Hold", gate); - addToggle ("adaptiveRelease", "Adaptive Release", gate); - - auto& det = addGroup ("Detection", { 590, 10, 260, 200 }); - addKnob ("cue", "Transient Cue", det); - addCombo ("sidechain", "Source", det); - addCombo ("lookahead", "Lookahead", det); - - auto& hum = addGroup ("Hum Removal", { 10, 220, 260, 220 }); - addToggle ("humEnable", "Enable", hum); - addCombo ("humBase", "Base", hum); - addKnob ("humHarmonics", "Harmonics", hum); - addKnob ("humStrength", "Strength", hum); - hum.addAndMakeVisible (humLearnButton); - hum.addAndMakeVisible (humStatus); - humLearnButton.onClick = [this] +#include + +class ActivityDisplay::ValueInterface final : public juce::AccessibilityValueInterface +{ +public: + explicit ValueInterface (ActivityDisplay& displayToUse) : display (displayToUse) {} + + bool isReadOnly() const override { return true; } + double getCurrentValue() const override { return display.reduction; } + juce::String getCurrentValueAsString() const override + { + return display.accessibleValueText(); + } + void setValue (double) override {} + void setValueAsString (const juce::String&) override {} + AccessibleValueRange getRange() const override { return {}; } + +private: + ActivityDisplay& display; +}; + +void ActivityDisplay::setState (float detectorDb, float gainReductionDb, + float thresholdDb, int bandMode, bool deltaAudition, + bool multibandLearning) +{ + const auto previousStatus = semanticStatus(); + const auto newReduction = juce::jmax (0.0f, -gainReductionDb); + const auto changed = juce::roundToInt (detector * 10.0f) + != juce::roundToInt (detectorDb * 10.0f) + || juce::roundToInt (reduction * 10.0f) + != juce::roundToInt (newReduction * 10.0f) + || juce::roundToInt (threshold * 10.0f) + != juce::roundToInt (thresholdDb * 10.0f) + || mode != bandMode || delta != deltaAudition + || learning != multibandLearning; + + if (! changed) + return; + + detector = detectorDb; + reduction = newReduction; + threshold = thresholdDb; + mode = bandMode; + delta = deltaAudition; + learning = multibandLearning; + setDescription (accessibleValueText()); + repaint(); + + if (semanticStatus() != previousStatus) + if (auto* handler = getAccessibilityHandler()) + handler->notifyAccessibilityEvent (juce::AccessibilityEvent::valueChanged); +} + +juce::String ActivityDisplay::semanticStatus() const +{ + if (delta) + return "OUTPUT: REMOVED SIGNAL"; + if (learning) + return "LEARN ARMED"; + if (mode > 0) + return "MULTIBAND MODE"; + if (detector <= -120.0f) + return "NO SIGNAL"; + return reduction > 0.5f ? "REDUCTION OBSERVED" : "NO REDUCTION"; +} + +juce::String ActivityDisplay::accessibleValueText() const +{ + juce::String detectorText; + if (mode > 0) + detectorText = "per-band detector; global detector unavailable"; + else if (detector <= -120.0f) + detectorText = "no input detected"; + else + detectorText = "detector " + juce::String (detector, 1) + " decibels"; + + const auto reductionText = learning + ? juce::String ("gain reduction unavailable while multiband learning is armed; suppression passes while processing") + : reduction >= 60.0f + ? juce::String ("at least 60 decibels gain reduction") + : juce::String (reduction, 1) + " decibels gain reduction"; + return "Last processing snapshot: " + semanticStatus().toLowerCase() + + ", " + detectorText + ", " + reductionText; +} + +std::unique_ptr ActivityDisplay::createAccessibilityHandler() +{ + return std::make_unique ( + *this, juce::AccessibilityRole::staticText, juce::AccessibilityActions {}, + juce::AccessibilityHandler::Interfaces { std::make_unique (*this) }); +} + +void ActivityDisplay::paint (juce::Graphics& graphics) +{ + auto bounds = getLocalBounds(); + auto statusBounds = bounds.removeFromTop (54); + const auto status = semanticStatus(); + const auto statusColour = status == "NO SIGNAL" || status == "LEARN ARMED" + || status == "OUTPUT: REMOVED SIGNAL" + ? SuppressorTheme::warning + : SuppressorTheme::accent; + + graphics.setColour (statusColour); + graphics.fillRoundedRectangle (static_cast (statusBounds.getX()), + static_cast (statusBounds.getY() + 8), + 4.0f, 31.0f, 2.0f); + graphics.setColour (SuppressorTheme::primaryText); + graphics.setFont (SuppressorTheme::makeFont (27.0f, true, 0.02f)); + graphics.drawFittedText (status, statusBounds.withTrimmedLeft (14), + juce::Justification::centredLeft, 1); + + auto drawCaption = [&graphics] (juce::Rectangle row, const juce::String& title, + const juce::String& value) { - proc.apvts.getParameter ("humLearn")->setValueNotifyingHost ( - proc.humLearning() ? 0.0f : 1.0f); + graphics.setColour (SuppressorTheme::secondaryText); + graphics.setFont (SuppressorTheme::makeFont (11.5f, true, 0.09f)); + graphics.drawText (title, row.removeFromLeft (row.getWidth() * 2 / 3), + juce::Justification::centredLeft); + graphics.setColour (SuppressorTheme::primaryText); + graphics.setFont (SuppressorTheme::makeFont (14.0f, true)); + graphics.drawText (value, row, juce::Justification::centredRight); }; - auto& bands = addGroup ("Multiband", { 280, 220, 300, 220 }); - addCombo ("bandMode", "Mode", bands); - bands.addAndMakeVisible (bandsLearnButton); - bands.addAndMakeVisible (bandsStatus); - bandsLearnButton.onClick = [this] + auto drawMeter = [&graphics] (juce::Rectangle meterBounds, float proportion) { - proc.apvts.getParameter ("bandsLearn")->setValueNotifyingHost ( - proc.bandsLearning() ? 0.0f : 1.0f); + auto track = meterBounds.toFloat(); + graphics.setColour (SuppressorTheme::divider); + graphics.fillRoundedRectangle (track, 2.5f); + track.setWidth (track.getWidth() * juce::jlimit (0.0f, 1.0f, proportion)); + graphics.setColour (SuppressorTheme::accent); + graphics.fillRoundedRectangle (track, 2.5f); }; - auto& out = addGroup ("Output", { 590, 220, 260, 220 }); - addToggle ("deltaAudition", "Delta Audition", out); - addKnob ("outputGain", "Gain", out); - out.addAndMakeVisible (detLabel); - out.addAndMakeVisible (grLabel); + bounds.removeFromTop (4); + auto detectorLabel = bounds.removeFromTop (22); + if (mode > 0) + { + drawCaption (detectorLabel, "DETECTOR", "PER-BAND"); + auto message = bounds.removeFromTop (25); + graphics.setColour (SuppressorTheme::secondaryText); + graphics.setFont (SuppressorTheme::makeFont (13.0f)); + graphics.drawFittedText ("GLOBAL LEVEL NOT EXPOSED", message, + juce::Justification::centredLeft, 1); + bounds.removeFromTop (13); + } + else + { + const auto detectorText = detector <= -120.0f + ? juce::String ("NO SIGNAL") + : juce::String (detector, 1) + " dB"; + drawCaption (detectorLabel, "DETECTOR", detectorText); + auto meter = bounds.removeFromTop (7).reduced (0, 1); + drawMeter (meter, juce::jmap (juce::jlimit (-80.0f, 0.0f, detector), + -80.0f, 0.0f, 0.0f, 1.0f)); + + const auto thresholdPosition = juce::jmap (juce::jlimit (-80.0f, 0.0f, threshold), + -80.0f, 0.0f, 0.0f, 1.0f); + const auto thresholdX = static_cast (meter.getX()) + + static_cast (meter.getWidth()) * thresholdPosition; + graphics.setColour (SuppressorTheme::primaryText); + graphics.drawVerticalLine (juce::roundToInt (thresholdX), + static_cast (meter.getY() - 2), + static_cast (meter.getBottom() + 2)); + bounds.removeFromTop (25); + } - setSize (860, 520); - startTimerHz (15); + auto reductionLabel = bounds.removeFromTop (22); + if (learning) + { + drawCaption (reductionLabel, "GAIN REDUCTION", "UNAVAILABLE"); + graphics.setColour (SuppressorTheme::secondaryText); + graphics.setFont (SuppressorTheme::makeFont (13.0f)); + graphics.drawFittedText ("SUPPRESSION PASSES WHILE PROCESSING", + bounds.removeFromTop (25), + juce::Justification::centredLeft, 1); + } + else + { + const auto reductionText = reduction >= 60.0f + ? juce::String ("60+ dB") + : juce::String (reduction, 1) + " dB"; + drawCaption (reductionLabel, "GAIN REDUCTION", reductionText); + auto reductionMeter = bounds.removeFromTop (7).reduced (0, 1); + drawMeter (reductionMeter, juce::jlimit (0.0f, 1.0f, reduction / 60.0f)); + } + + bounds.removeFromTop (14); + graphics.setColour (SuppressorTheme::secondaryText); + graphics.setFont (SuppressorTheme::makeFont (12.5f)); + graphics.drawFittedText ("UPDATES WHILE PROCESSING", bounds.removeFromTop (18), + juce::Justification::centredLeft, 1); } -void SuppressorEditor::addKnob (const juce::String& paramId, const juce::String& text, - juce::Component& parent) +SuppressorEditor::SuppressorEditor (SuppressorProcessor& processorToUse) + : juce::AudioProcessorEditor (&processorToUse), proc (processorToUse) { - sliders.push_back (std::make_unique()); - auto* s = sliders.back().get(); - s->setSliderStyle (juce::Slider::RotaryVerticalDrag); - s->setTextBoxStyle (juce::Slider::TextBoxBelow, false, 70, 18); - s->setRotaryParameters (juce::MathConstants::pi * 1.25f, - juce::MathConstants::pi * 2.75f, true); - parent.addAndMakeVisible (*s); + setLookAndFeel (&lookAndFeel); + setOpaque (true); + + activityDisplay.setName ("Suppression activity"); + activityDisplay.setTitle ("Suppression activity"); + activityDisplay.setExplicitFocusOrder (nextFocusOrder++); + addAndMakeVisible (activityDisplay); + + addCombo ("gateMode", "Gate Mode"); + addToggle ("adaptiveRelease", "Adaptive Release"); + auto& suppressionStrength = addKnob ("strength", "Strength"); + suppressionStrength.setName ("Suppression Strength"); + suppressionStrength.setTitle ("Suppression Strength"); + addKnob ("threshold", "Threshold"); + addKnob ("release", "Release"); + addKnob ("depth", "Depth"); + addKnob ("hysteresis", "Hysteresis"); + addKnob ("hold", "Hold"); + + addKnob ("cue", "Transient Cue"); + addCombo ("sidechain", "Detector Source"); + addCombo ("lookahead", "Lookahead"); + + addToggle ("humEnable", "Hum Removal"); + addCombo ("humBase", "Mains Family"); + addKnob ("humHarmonics", "Harmonics"); + addKnob ("humStrength", "Hum Strength"); + addAction ("humLearn", humLearnButton, "Learn hum profile"); - auto e = std::make_unique(); - e->id = paramId; - e->slider = s; - e->sliderAttachment = std::make_unique ( - proc.apvts, paramId, *s); - e->label = std::make_unique(); - e->label->setText (text, juce::dontSendNotification); - e->label->setJustificationType (juce::Justification::centred); - parent.addAndMakeVisible (*e->label); - entries.push_back (std::move (e)); + addCombo ("bandMode", "Band Mode"); + addAction ("bandsLearn", bandsLearnButton, "Learn multiband thresholds"); + addKnob ("outputGain", "Output Gain"); + addToggle ("deltaAudition", "Delta Audition"); + + auto setupStatus = [this] (juce::Label& label, const juce::String& name) + { + label.setName (name); + label.setTitle (name); + label.setFont (SuppressorTheme::makeFont (13.0f, true, 0.03f)); + label.setColour (juce::Label::textColourId, SuppressorTheme::secondaryText); + label.setJustificationType (juce::Justification::centredLeft); + label.setMinimumHorizontalScale (0.72f); + addAndMakeVisible (label); + }; + setupStatus (humStatus, "Hum learning status"); + setupStatus (bandsStatus, "Multiband learning status"); + + setSize (1000, 640); + updateModePresentation(); + timerCallback(); + updateTimerState(); +} + +SuppressorEditor::~SuppressorEditor() +{ + stopTimer(); + for (const auto& slider : sliders) + slider->endActiveGesture(); + entries.clear(); + setLookAndFeel (nullptr); } -void SuppressorEditor::addToggle (const juce::String& paramId, const juce::String& text, - juce::Component& parent) +ParameterSlider& SuppressorEditor::addKnob (const juce::String& parameterId, + const juce::String& labelText) { - toggleButtons.push_back (std::make_unique (text)); - auto* b = toggleButtons.back().get(); - parent.addAndMakeVisible (*b); + sliders.push_back (std::make_unique()); + auto* slider = sliders.back().get(); + addAndMakeVisible (*slider); + slider->setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + slider->setRotaryParameters (juce::MathConstants::pi * 1.25f, + juce::MathConstants::pi * 2.75f, true); + slider->setTextBoxStyle (juce::Slider::TextBoxBelow, false, 88, 24); + slider->setMouseDragSensitivity (220); + slider->setVelocityBasedMode (false); + slider->setVelocityModeParameters (0.24, 1, 0.0, true, + juce::ModifierKeys::shiftModifier); + slider->setScrollWheelEnabled (false); + slider->setWantsKeyboardFocus (true); + slider->setExplicitFocusOrder (nextFocusOrder++); + slider->setComponentID (parameterId); + slider->setName (labelText); + slider->setTitle (labelText); + slider->setDescription ("Drag to adjust. Shift-drag for fine control. Double-click or press Return for exact entry. Alt/Option-click resets to default."); + slider->setTooltip ("Return/double-click: exact. Shift-drag: fine. Alt/Option-click: reset."); - auto e = std::make_unique(); - e->id = paramId; - e->button = b; - e->buttonAttachment = std::make_unique ( - proc.apvts, paramId, *b); - entries.push_back (std::move (e)); + auto entry = std::make_unique(); + entry->id = parameterId; + entry->slider = slider; + entry->label = std::make_unique(); + entry->label->setText (labelText.toUpperCase(), juce::dontSendNotification); + entry->label->setFont (SuppressorTheme::makeFont (11.5f, true, 0.08f)); + entry->label->setColour (juce::Label::textColourId, SuppressorTheme::secondaryText); + entry->label->setJustificationType (juce::Justification::centred); + entry->label->setInterceptsMouseClicks (false, false); + entry->label->setAccessible (false); + addAndMakeVisible (*entry->label); + entry->sliderAttachment = std::make_unique< + juce::AudioProcessorValueTreeState::SliderAttachment> (proc.apvts, parameterId, *slider); + if (auto* parameter = proc.apvts.getParameter (parameterId)) + { + slider->setDoubleClickReturnValue ( + true, parameter->convertFrom0to1 (parameter->getDefaultValue()), + juce::ModifierKeys::altModifier); + slider->setKeyboardGestureCallbacks ( + [parameter] { parameter->beginChangeGesture(); }, + [parameter] { parameter->endChangeGesture(); }); + if (parameter->getLabel().isNotEmpty()) + slider->setTextValueSuffix (" " + parameter->getLabel()); + } + entries.push_back (std::move (entry)); + return *slider; } -void SuppressorEditor::addCombo (const juce::String& paramId, const juce::String& text, - juce::Component& parent) +juce::ComboBox& SuppressorEditor::addCombo (const juce::String& parameterId, + const juce::String& labelText) { comboBoxes.push_back (std::make_unique()); - auto* c = comboBoxes.back().get(); - if (auto* p = dynamic_cast (proc.apvts.getParameter (paramId))) - c->addItemList (p->choices, 1); - parent.addAndMakeVisible (*c); - - auto e = std::make_unique(); - e->id = paramId; - e->combo = c; - e->comboAttachment = std::make_unique ( - proc.apvts, paramId, *c); - e->label = std::make_unique(); - e->label->setText (text, juce::dontSendNotification); - e->label->setJustificationType (juce::Justification::centred); - parent.addAndMakeVisible (*e->label); - entries.push_back (std::move (e)); -} - -juce::GroupComponent& SuppressorEditor::addGroup (const juce::String& title, - juce::Rectangle bounds) -{ - auto g = std::make_unique (juce::String(), title); - g->setBounds (bounds); - addAndMakeVisible (*g); - groups.push_back (std::move (g)); - return *groups.back(); -} - -void SuppressorEditor::place (const juce::String& paramId, juce::Rectangle b, - bool labelBelow) -{ - for (auto& e : entries) - if (e->id == paramId) + auto* combo = comboBoxes.back().get(); + if (auto* parameter = dynamic_cast ( + proc.apvts.getParameter (parameterId))) + combo->addItemList (parameter->choices, 1); + combo->setJustificationType (juce::Justification::centredLeft); + combo->setWantsKeyboardFocus (true); + combo->setExplicitFocusOrder (nextFocusOrder++); + combo->setComponentID (parameterId); + combo->setName (labelText); + combo->setTitle (labelText); + combo->setDescription ("Choose " + labelText.toLowerCase()); + combo->setTooltip (combo->getDescription()); + addAndMakeVisible (*combo); + + auto entry = std::make_unique(); + entry->id = parameterId; + entry->combo = combo; + entry->label = std::make_unique(); + entry->label->setText (labelText.toUpperCase(), juce::dontSendNotification); + entry->label->setFont (SuppressorTheme::makeFont (11.5f, true, 0.08f)); + entry->label->setColour (juce::Label::textColourId, SuppressorTheme::secondaryText); + entry->label->setJustificationType (juce::Justification::centredLeft); + entry->label->setInterceptsMouseClicks (false, false); + entry->label->setAccessible (false); + addAndMakeVisible (*entry->label); + entry->comboAttachment = std::make_unique< + juce::AudioProcessorValueTreeState::ComboBoxAttachment> (proc.apvts, parameterId, *combo); + entries.push_back (std::move (entry)); + + if (parameterId == "gateMode" || parameterId == "bandMode") + combo->onChange = [this] { - if (e->slider != nullptr) - { - e->slider->setBounds (b.getX(), b.getY(), b.getWidth(), b.getHeight()); - if (e->label != nullptr) - e->label->setBounds (b.getX() - 5, b.getY() + b.getHeight(), - b.getWidth() + 10, kLabelH); - } - else if (e->combo != nullptr) + updateModePresentation(); + timerCallback(); + }; + + return *combo; +} + +juce::ToggleButton& SuppressorEditor::addToggle (const juce::String& parameterId, + const juce::String& labelText) +{ + toggleButtons.push_back (std::make_unique (labelText.toUpperCase())); + auto* button = toggleButtons.back().get(); + button->setWantsKeyboardFocus (true); + button->setExplicitFocusOrder (nextFocusOrder++); + button->setComponentID (parameterId); + button->setName (labelText); + button->setTitle (labelText); + button->setTooltip ("Toggle " + labelText.toLowerCase()); + addAndMakeVisible (*button); + + auto entry = std::make_unique(); + entry->id = parameterId; + entry->button = button; + entry->buttonAttachment = std::make_unique< + juce::AudioProcessorValueTreeState::ButtonAttachment> (proc.apvts, parameterId, *button); + entries.push_back (std::move (entry)); + + if (parameterId == "humEnable") + button->onClick = [this] { updateModePresentation(); }; + + return *button; +} + +void SuppressorEditor::addAction (const juce::String& parameterId, + juce::TextButton& button, + const juce::String& accessibleName) +{ + button.setClickingTogglesState (true); + button.setWantsKeyboardFocus (true); + button.setExplicitFocusOrder (nextFocusOrder++); + button.setComponentID (parameterId); + button.setName (accessibleName); + button.setTitle (accessibleName); + button.setTooltip (accessibleName); + addAndMakeVisible (button); + + auto entry = std::make_unique(); + entry->id = parameterId; + entry->button = &button; + entry->buttonAttachment = std::make_unique< + juce::AudioProcessorValueTreeState::ButtonAttachment> (proc.apvts, parameterId, button); + entries.push_back (std::move (entry)); +} + +SuppressorEditor::Entry* SuppressorEditor::findEntry (const juce::String& parameterId) const +{ + for (const auto& entry : entries) + if (entry->id == parameterId) + return entry.get(); + return nullptr; +} + +void SuppressorEditor::placeKnob (const juce::String& parameterId, + juce::Rectangle bounds) +{ + if (auto* entry = findEntry (parameterId)) + { + entry->label->setBounds (bounds.removeFromTop (18)); + entry->slider->setBounds (bounds); + } +} + +void SuppressorEditor::placeCombo (const juce::String& parameterId, + juce::Rectangle bounds) +{ + if (auto* entry = findEntry (parameterId)) + { + entry->label->setBounds (bounds.getX(), bounds.getY() - 17, + bounds.getWidth(), 15); + entry->combo->setBounds (bounds); + } +} + +void SuppressorEditor::placeButton (const juce::String& parameterId, + juce::Rectangle bounds) +{ + if (auto* entry = findEntry (parameterId)) + entry->button->setBounds (bounds); +} + +void SuppressorEditor::resized() +{ + coreBounds = { 18, 82, 650, 250 }; + activityBounds = { 680, 82, 302, 250 }; + detectionBounds = { 18, 344, 262, 278 }; + humBounds = { 292, 344, 392, 278 }; + outputBounds = { 696, 344, 286, 278 }; + + placeCombo ("gateMode", { coreBounds.getX() + 14, coreBounds.getY() + 51, 168, 34 }); + placeButton ("adaptiveRelease", + { coreBounds.getX() + 196, coreBounds.getY() + 51, 178, 34 }); + + const auto knobWidth = (coreBounds.getWidth() - 28) / 6; + const juce::StringArray coreKnobs { "strength", "threshold", "release", + "depth", "hysteresis", "hold" }; + for (int index = 0; index < coreKnobs.size(); ++index) + placeKnob (coreKnobs[index], + { coreBounds.getX() + 14 + index * knobWidth, + coreBounds.getY() + 94, knobWidth, 142 }); + + auto activityContent = activityBounds.reduced (14); + activityContent.removeFromTop (28); + activityDisplay.setBounds (activityContent); + + placeKnob ("cue", { detectionBounds.getX() + 14, detectionBounds.getY() + 41, + 110, 218 }); + placeCombo ("sidechain", + { detectionBounds.getX() + 138, detectionBounds.getY() + 69, 110, 34 }); + placeCombo ("lookahead", + { detectionBounds.getX() + 138, detectionBounds.getY() + 145, 110, 34 }); + + placeButton ("humEnable", + { humBounds.getX() + 14, humBounds.getY() + 48, 132, 34 }); + placeCombo ("humBase", + { humBounds.getX() + 250, humBounds.getY() + 48, 128, 34 }); + placeKnob ("humHarmonics", + { humBounds.getX() + 14, humBounds.getY() + 96, 108, 164 }); + placeKnob ("humStrength", + { humBounds.getX() + 126, humBounds.getY() + 96, 108, 164 }); + placeButton ("humLearn", + { humBounds.getX() + 250, humBounds.getY() + 111, 128, 36 }); + humStatus.setBounds (humBounds.getX() + 250, humBounds.getY() + 154, 128, 62); + + placeCombo ("bandMode", + { outputBounds.getX() + 14, outputBounds.getY() + 50, 122, 34 }); + placeButton ("bandsLearn", + { outputBounds.getX() + 150, outputBounds.getY() + 50, 122, 34 }); + bandsStatus.setBounds (outputBounds.getX() + 14, outputBounds.getY() + 91, 258, 25); + placeKnob ("outputGain", + { outputBounds.getX() + 14, outputBounds.getY() + 139, 110, 124 }); + placeButton ("deltaAudition", + { outputBounds.getX() + 140, outputBounds.getY() + 175, 132, 38 }); +} + +void SuppressorEditor::updateModePresentation() +{ + const auto bandMode = findEntry ("bandMode")->combo->getSelectedItemIndex(); + const auto gateMode = findEntry ("gateMode")->combo->getSelectedItemIndex(); + const auto humEnabled = findEntry ("humEnable")->button->getToggleState(); + + auto setEmphasis = [this] (const juce::String& parameterId, bool active) + { + if (auto* entry = findEntry (parameterId)) + if (entry->slider != nullptr) { - if (e->label != nullptr && labelBelow) - e->label->setBounds (b.getX(), b.getY() - kLabelH - 2, - b.getWidth(), kLabelH); - e->combo->setBounds (b); + entry->slider->setColour (juce::Slider::rotarySliderFillColourId, + active ? SuppressorTheme::accent + : SuppressorTheme::inactiveControl); + entry->slider->repaint(); } - else if (e->button != nullptr) + }; + + setEmphasis ("strength", bandMode <= 0); + setEmphasis ("threshold", bandMode <= 0); + setEmphasis ("depth", gateMode <= 0); + setEmphasis ("humHarmonics", humEnabled); + setEmphasis ("humStrength", humEnabled); + + auto setApplicability = [this] (const juce::String& parameterId, bool active, + const juce::String& inactiveReason) + { + if (auto* entry = findEntry (parameterId)) + if (entry->slider != nullptr) { - e->button->setBounds (b); + const auto interaction = " Drag to adjust; Shift-drag is fine control; double-click or Return opens exact entry; Alt/Option-click resets."; + const auto description = active + ? entry->slider->getName() + "." + interaction + : "Inactive but editable. " + inactiveReason + interaction; + entry->slider->setDescription (description); + entry->slider->setTooltip (active + ? "Return/double-click: exact. Shift-drag: fine. Alt/Option-click: reset." + : inactiveReason + " Still editable."); } - } -} + }; -void SuppressorEditor::resized() -{ - const int kw = kKnobW, kh = kKnobH, dx = kw + 6; - - // Core group {10,10,260,200} - place ("strength", { 12, 30, kw, kh }); - place ("threshold", { 12 + dx, 30, kw, kh }); - place ("release", { 12 + 2 * dx, 30, kw, kh }); - - // Gate group {280,10,300,200} - place ("gateMode", { 12, 60, 130, 24 }); - place ("depth", { 12, 90, kw, kh }); - place ("hysteresis", { 12 + dx, 90, kw, kh }); - place ("hold", { 12 + 2 * dx, 90, kw, kh }); - place ("adaptiveRelease", { 170, 30, 150, 24 }); - - // Detection group {590,10,260,200} - place ("cue", { 12, 90, kw, kh }); - place ("sidechain", { 140, 60, 110, 24 }); - place ("lookahead", { 140, 150, 110, 24 }); - - // Hum group {10,220,260,220} - place ("humEnable", { 12, 30, 90, 24 }); - place ("humBase", { 130, 30, 110, 24 }); - place ("humHarmonics", { 12, 90, kw, kh }); - place ("humStrength", { 12 + dx, 90, kw, kh }); - humLearnButton.setBounds (12, 190, 80, 24); - humStatus.setBounds (100, 190, 150, 24); - - // Multiband group {280,220,300,220} - place ("bandMode", { 12, 60, 130, 24 }); - bandsLearnButton.setBounds (12, 190, 80, 24); - bandsStatus.setBounds (100, 190, 180, 24); - - // Output group {590,220,260,220} - place ("deltaAudition", { 12, 30, 140, 24 }); - place ("outputGain", { 12, 90, kw, kh }); - detLabel.setBounds (120, 100, 130, 20); - grLabel.setBounds (120, 124, 130, 20); + setApplicability ("strength", bandMode <= 0, "Applies only in One-Split mode."); + setApplicability ("threshold", bandMode <= 0, "Global Threshold applies only in One-Split mode."); + setApplicability ("depth", gateMode <= 0, "Tight Gate uses a hard-zero floor; this Reduction depth is retained for automation and preconfiguration."); + setApplicability ("humHarmonics", humEnabled, "Hum Removal is off; this setting is retained."); + setApplicability ("humStrength", humEnabled, "Hum Removal is off; this setting is retained."); } void SuppressorEditor::timerCallback() { - detLabel.setText ("Det: " + juce::String (proc.detectorDb(), 1) + " dB", - juce::dontSendNotification); - grLabel.setText ("GR: " + juce::String (proc.gainReductionDb(), 1) + " dB", - juce::dontSendNotification); - - if (proc.humLearning()) - humStatus.setText ("learning...", juce::dontSendNotification); - else if (proc.humLocked()) - humStatus.setText (juce::String (proc.humDetectedBaseHz(), 0) + " Hz, " - + juce::String (proc.humActiveDips()) + " dips", - juce::dontSendNotification); - else - humStatus.setText ("not learned", juce::dontSendNotification); + const auto desiredIntervalMs = isShowing() ? 50 : 500; + if (getTimerInterval() != desiredIntervalMs) + startTimer (desiredIntervalMs); + + const auto bandMode = juce::jmax (0, findEntry ("bandMode")->combo->getSelectedItemIndex()); + const auto threshold = static_cast (findEntry ("threshold")->slider->getValue()); + const auto deltaAudition = findEntry ("deltaAudition")->button->getToggleState(); + const auto bandsLearning = bandsLearnButton.getToggleState(); + activityDisplay.setState (proc.detectorDb(), proc.gainReductionDb(), threshold, + bandMode, deltaAudition, bandsLearning && bandMode > 0); - if (proc.bandsLearning()) - bandsStatus.setText ("learning...", juce::dontSendNotification); - else if (proc.bandsLocked()) - bandsStatus.setText ("thresholds locked", juce::dontSendNotification); + const auto humLearning = humLearnButton.getToggleState(); + humLearnButton.setButtonText (humLearning ? "STOP LEARNING" : "LEARN"); + humLearnButton.setName (humLearning ? "Stop hum learning" : "Learn hum profile"); + humLearnButton.setTitle (humLearnButton.getName()); + humLearnButton.setTooltip (humLearning + ? "Stop the armed request. Capture and hum pass-through occur while processing." + : "Arm hum-profile learning; capture begins while processing."); + humStatus.setText (humLearning ? "ARMED / PASSES IN PROCESS" + : "READY TO LEARN", + juce::dontSendNotification); + + const auto multibandMode = bandMode > 0; + bandsLearnButton.setButtonText (bandsLearning ? "STOP LEARNING" + : multibandMode ? "LEARN" + : "ARM LEARNING"); + bandsLearnButton.setName (bandsLearning ? "Stop multiband learning" + : multibandMode ? "Learn multiband thresholds" + : "Arm multiband learning"); + bandsLearnButton.setTitle (bandsLearnButton.getName()); + bandsLearnButton.setTooltip ( + bandsLearning + ? multibandMode + ? "Stop the armed request. Suppression passes while learning during processing." + : "Stop the armed request; it is waiting for 4/6-Band." + : multibandMode + ? "Arm multiband learning; suppression passes while processing." + : "Arm multiband learning now; capture waits for 4/6-Band processing."); + if (bandsLearning) + bandsStatus.setText (multibandMode ? "ARMED / PASSES WHILE PROCESSING" + : "ARMED / WAITING FOR 4/6-BAND", + juce::dontSendNotification); else - bandsStatus.setText ("not learned", juce::dontSendNotification); + bandsStatus.setText (multibandMode ? "READY TO LEARN" + : "CAN ARM FOR 4/6-BAND", + juce::dontSendNotification); + + updateModePresentation(); +} + +void SuppressorEditor::visibilityChanged() +{ + updateTimerState(); } -void SuppressorEditor::paint (juce::Graphics& g) +void SuppressorEditor::updateTimerState() { - g.fillAll (kBg); - g.setColour (kText); - g.setFont (16.0f); - g.drawText ("suppressor", 12, 452, 200, 24, juce::Justification::left); - g.setColour (juce::Colour (0xff6a6d75)); - g.setFont (11.0f); - g.drawText ("zero-latency frequency-split noise suppressor", 12, 474, 400, 20, - juce::Justification::left); + startTimer (isShowing() ? 50 : 500); +} + +int SuppressorEditor::getControlParameterIndex (juce::Component& component) +{ + for (const auto& entry : entries) + { + auto* control = entry->slider != nullptr + ? static_cast (entry->slider) + : (entry->combo != nullptr + ? static_cast (entry->combo) + : static_cast (entry->button)); + if (control == &component || control->isParentOf (&component)) + return proc.getParameters().indexOf (proc.apvts.getParameter (entry->id)); + } + + return -1; +} + +void SuppressorEditor::paintPanel (juce::Graphics& graphics, + juce::Rectangle bounds, + const juce::String& title) const +{ + const auto panel = bounds.toFloat(); + graphics.setColour (SuppressorTheme::surface); + graphics.fillRoundedRectangle (panel, SuppressorTheme::cornerRadius); + graphics.setColour (SuppressorTheme::divider); + graphics.drawRoundedRectangle (panel.reduced (0.5f), SuppressorTheme::cornerRadius, 1.0f); + + graphics.setColour (SuppressorTheme::secondaryText); + graphics.setFont (SuppressorTheme::makeFont (11.5f, true, 0.10f)); + graphics.drawText (title, bounds.getX() + 14, bounds.getY() + 8, + bounds.getWidth() - 28, 15, juce::Justification::centredLeft); + graphics.setColour (SuppressorTheme::divider); + graphics.drawHorizontalLine (bounds.getY() + 29, + static_cast (bounds.getX() + 14), + static_cast (bounds.getRight() - 14)); +} + +void SuppressorEditor::paint (juce::Graphics& graphics) +{ + graphics.fillAll (SuppressorTheme::canvas); + graphics.setColour (SuppressorTheme::header); + graphics.fillRect (0, 0, getWidth(), 64); + graphics.setColour (SuppressorTheme::divider); + graphics.drawHorizontalLine (63, 0.0f, static_cast (getWidth())); + + graphics.setColour (SuppressorTheme::accent); + graphics.fillRoundedRectangle (18.0f, 16.0f, 5.0f, 32.0f, 2.5f); + graphics.setColour (SuppressorTheme::primaryText); + graphics.setFont (SuppressorTheme::makeFont (24.0f, true, 0.03f)); + graphics.drawText ("SUPPRESSOR", 36, 8, 220, 29, juce::Justification::centredLeft); + graphics.setColour (SuppressorTheme::secondaryText); + graphics.setFont (SuppressorTheme::makeFont (11.0f, true, 0.11f)); + graphics.drawText ("FREQUENCY-SPLIT NOISE CONTROL", 36, 35, 300, 15, + juce::Justification::centredLeft); + graphics.drawText ("DYNAMIC HIGH-BAND PROCESSOR", getWidth() - 300, 24, 282, 16, + juce::Justification::centredRight); + + paintPanel (graphics, coreBounds, "SUPPRESSION CORE"); + paintPanel (graphics, activityBounds, "ACTIVITY / LAST PROCESSING SNAPSHOT"); + paintPanel (graphics, detectionBounds, "DETECTION"); + paintPanel (graphics, humBounds, "HUM FILTER"); + paintPanel (graphics, outputBounds, "MULTIBAND / OUTPUT"); + + graphics.setColour (SuppressorTheme::divider); + graphics.drawHorizontalLine (outputBounds.getY() + 128, + static_cast (outputBounds.getX() + 14), + static_cast (outputBounds.getRight() - 14)); } diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index af1ef47..aa6e659 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -1,52 +1,91 @@ #pragma once -#include -#include +#include "PluginLookAndFeel.h" #include "PluginProcessor.h" +#include + +class ActivityDisplay final : public juce::Component +{ +public: + void setState (float detectorDb, float gainReductionDb, float thresholdDb, + int bandMode, bool deltaAudition, bool multibandLearning); + void paint (juce::Graphics&) override; + std::unique_ptr createAccessibilityHandler() override; + +private: + class ValueInterface; + juce::String accessibleValueText() const; + juce::String semanticStatus() const; -class SuppressorEditor : public juce::AudioProcessorEditor, - private juce::Timer + float detector = -180.0f; + float reduction = 0.0f; + float threshold = -40.0f; + int mode = 0; + bool delta = false; + bool learning = false; +}; + +class SuppressorEditor final : public juce::AudioProcessorEditor, + private juce::Timer { public: explicit SuppressorEditor (SuppressorProcessor&); - ~SuppressorEditor() override = default; + ~SuppressorEditor() override; void paint (juce::Graphics&) override; void resized() override; + void visibilityChanged() override; + int getControlParameterIndex (juce::Component&) override; private: - void timerCallback() override; - struct Entry { juce::String id; - juce::Slider* slider = nullptr; // when a knob - juce::ComboBox* combo = nullptr; // when a combo - juce::Button* button = nullptr; // when a toggle + ParameterSlider* slider = nullptr; + juce::ComboBox* combo = nullptr; + juce::Button* button = nullptr; std::unique_ptr label; std::unique_ptr sliderAttachment; std::unique_ptr comboAttachment; std::unique_ptr buttonAttachment; }; - void addKnob (const juce::String& paramId, const juce::String& text, juce::Component& parent); - void addToggle (const juce::String& paramId, const juce::String& text, juce::Component& parent); - void addCombo (const juce::String& paramId, const juce::String& text, juce::Component& parent); - juce::GroupComponent& addGroup (const juce::String& title, juce::Rectangle bounds); - void place (const juce::String& paramId, juce::Rectangle knobBounds, - bool labelBelow = true); + void timerCallback() override; + ParameterSlider& addKnob (const juce::String& parameterId, const juce::String& label); + juce::ComboBox& addCombo (const juce::String& parameterId, const juce::String& label); + juce::ToggleButton& addToggle (const juce::String& parameterId, const juce::String& label); + void addAction (const juce::String& parameterId, juce::TextButton& button, + const juce::String& accessibleName); + Entry* findEntry (const juce::String& parameterId) const; + void placeKnob (const juce::String& parameterId, juce::Rectangle bounds); + void placeCombo (const juce::String& parameterId, juce::Rectangle bounds); + void placeButton (const juce::String& parameterId, juce::Rectangle bounds); + void updateModePresentation(); + void updateTimerState(); + void paintPanel (juce::Graphics&, juce::Rectangle, const juce::String&) const; SuppressorProcessor& proc; - std::vector> sliders; + SuppressorLookAndFeel lookAndFeel; + ActivityDisplay activityDisplay; + juce::TooltipWindow tooltipWindow { this, 1200 }; + + std::vector> sliders; std::vector> comboBoxes; std::vector> toggleButtons; std::vector> entries; - std::vector> groups; - juce::TextButton humLearnButton { "Learn" }; - juce::TextButton bandsLearnButton { "Learn" }; - juce::Label humStatus, bandsStatus; - juce::Label detLabel, grLabel; + ParameterTextButton humLearnButton { "LEARN" }; + ParameterTextButton bandsLearnButton { "LEARN" }; + juce::Label humStatus; + juce::Label bandsStatus; + + juce::Rectangle coreBounds; + juce::Rectangle activityBounds; + juce::Rectangle detectionBounds; + juce::Rectangle humBounds; + juce::Rectangle outputBounds; + + int nextFocusOrder = 1; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SuppressorEditor) }; diff --git a/Source/PluginLookAndFeel.cpp b/Source/PluginLookAndFeel.cpp new file mode 100644 index 0000000..62409af --- /dev/null +++ b/Source/PluginLookAndFeel.cpp @@ -0,0 +1,455 @@ +#include "PluginLookAndFeel.h" +#include "SuppressorAssets.h" + +#include + +namespace +{ +const juce::Typeface::Ptr& regularTypeface() +{ + static auto typeface = juce::Typeface::createSystemTypefaceFor ( + SuppressorAssets::BarlowCondensedRegular_ttf, + SuppressorAssets::BarlowCondensedRegular_ttfSize); + return typeface; +} + +const juce::Typeface::Ptr& semiboldTypeface() +{ + static auto typeface = juce::Typeface::createSystemTypefaceFor ( + SuppressorAssets::BarlowCondensedSemiBold_ttf, + SuppressorAssets::BarlowCondensedSemiBold_ttfSize); + return typeface; +} + +juce::TextLayout makeTooltipLayout (const juce::String& text, juce::Colour colour, + float maximumWidth) +{ + juce::AttributedString attributed; + attributed.setJustification (juce::Justification::centred); + attributed.setWordWrap (juce::AttributedString::byWord); + attributed.append (text, SuppressorTheme::makeFont (14.0f), colour); + juce::TextLayout layout; + layout.createLayout (attributed, maximumWidth); + return layout; +} +} + +juce::Font SuppressorTheme::makeFont (float size, bool semibold, float tracking) +{ + auto typeface = semibold ? semiboldTypeface() : regularTypeface(); + return juce::Font (juce::FontOptions (typeface) + .withHeight (size) + .withKerningFactor (tracking)); +} + +void ParameterSlider::mouseDoubleClick (const juce::MouseEvent& event) +{ + if (isEnabled() && isTextBoxEditable() && ! event.mods.isPopupMenu()) + showTextBox(); +} + +bool ParameterSlider::keyPressed (const juce::KeyPress& key) +{ + if (key == juce::KeyPress::returnKey && isEnabled() && isTextBoxEditable()) + { + showTextBox(); + return true; + } + + const auto isAdjustmentKey = ! key.getModifiers().isAnyModifierKeyDown() + && (key.isKeyCode (juce::KeyPress::leftKey) + || key.isKeyCode (juce::KeyPress::rightKey) + || key.isKeyCode (juce::KeyPress::upKey) + || key.isKeyCode (juce::KeyPress::downKey)); + if (! isEnabled() || ! isAdjustmentKey || getThumbBeingDragged() >= 0) + return juce::Slider::keyPressed (key); + + if (beginKeyboardGesture != nullptr) + beginKeyboardGesture(); + const auto handled = juce::Slider::keyPressed (key); + if (endKeyboardGesture != nullptr) + endKeyboardGesture(); + return handled; +} + +void ParameterSlider::setKeyboardGestureCallbacks (GestureCallback begin, + GestureCallback end) +{ + beginKeyboardGesture = std::move (begin); + endKeyboardGesture = std::move (end); +} + +void ParameterSlider::endActiveGesture() +{ + if (getThumbBeingDragged() < 0) + return; + + const auto now = juce::Time::getCurrentTime(); + const auto position = getLocalBounds().getCentre().toFloat(); + juce::Slider::mouseUp ( + { juce::Desktop::getInstance().getMainMouseSource(), position, {}, + 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, this, this, now, position, now, 1, true }); +} + +double ParameterSlider::getValueFromText (const juce::String& text) +{ + auto numericText = text.trim(); + const auto suffix = getTextValueSuffix().trim(); + if (suffix.isNotEmpty() && numericText.endsWithIgnoreCase (suffix)) + numericText = numericText.dropLastCharacters (suffix.length()).trimEnd(); + + bool hasDigit = false; + bool hasDecimalPoint = false; + for (int index = 0; index < numericText.length(); ++index) + { + const auto character = numericText[index]; + if (character >= '0' && character <= '9') + { + hasDigit = true; + continue; + } + if (character == '.' && ! hasDecimalPoint) + { + hasDecimalPoint = true; + continue; + } + if (index == 0 && (character == '+' || character == '-')) + continue; + return getValue(); + } + + if (! hasDigit) + return getValue(); + + const auto parsed = numericText.getDoubleValue(); + return std::isfinite (parsed) ? juce::Slider::getValueFromText (numericText) + : getValue(); +} + +bool ParameterToggleButton::keyPressed (const juce::KeyPress& key) +{ + if (isEnabled() && key.isKeyCode (' ')) + { + setToggleState (! getToggleState(), juce::sendNotificationSync); + return true; + } + return juce::ToggleButton::keyPressed (key); +} + +bool ParameterTextButton::keyPressed (const juce::KeyPress& key) +{ + if (isEnabled() && key.isKeyCode (' ')) + { + setToggleState (! getToggleState(), juce::sendNotificationSync); + return true; + } + return juce::TextButton::keyPressed (key); +} + +SuppressorLookAndFeel::SuppressorLookAndFeel() +{ + setColour (juce::Slider::rotarySliderFillColourId, SuppressorTheme::accent); + setColour (juce::Slider::rotarySliderOutlineColourId, SuppressorTheme::controlOutline); + setColour (juce::Slider::textBoxTextColourId, SuppressorTheme::primaryText); + setColour (juce::Slider::textBoxBackgroundColourId, juce::Colours::transparentBlack); + setColour (juce::Slider::textBoxOutlineColourId, juce::Colours::transparentBlack); + setColour (juce::Slider::textBoxHighlightColourId, SuppressorTheme::accent.withAlpha (0.28f)); + setColour (juce::ComboBox::textColourId, SuppressorTheme::primaryText); + setColour (juce::ComboBox::backgroundColourId, SuppressorTheme::surfaceRaised); + setColour (juce::ComboBox::outlineColourId, SuppressorTheme::controlOutline); + setColour (juce::ComboBox::arrowColourId, SuppressorTheme::secondaryText); + setColour (juce::ComboBox::focusedOutlineColourId, SuppressorTheme::accent); + setColour (juce::PopupMenu::backgroundColourId, SuppressorTheme::surfaceRaised); + setColour (juce::PopupMenu::textColourId, SuppressorTheme::primaryText); + setColour (juce::PopupMenu::highlightedBackgroundColourId, SuppressorTheme::accent); + setColour (juce::PopupMenu::highlightedTextColourId, SuppressorTheme::canvas); + setColour (juce::Label::textColourId, SuppressorTheme::primaryText); + setColour (juce::Label::textWhenEditingColourId, SuppressorTheme::primaryText); + setColour (juce::Label::backgroundWhenEditingColourId, SuppressorTheme::surfaceRaised); + setColour (juce::Label::outlineWhenEditingColourId, SuppressorTheme::accent); + setColour (juce::TextEditor::textColourId, SuppressorTheme::primaryText); + setColour (juce::TextEditor::backgroundColourId, SuppressorTheme::surfaceRaised); + setColour (juce::TextEditor::highlightColourId, SuppressorTheme::accent.withAlpha (0.35f)); + setColour (juce::TextEditor::highlightedTextColourId, SuppressorTheme::primaryText); + setColour (juce::CaretComponent::caretColourId, SuppressorTheme::accent); + setColour (juce::TooltipWindow::backgroundColourId, SuppressorTheme::surfaceRaised); + setColour (juce::TooltipWindow::textColourId, SuppressorTheme::primaryText); + setColour (juce::TooltipWindow::outlineColourId, SuppressorTheme::controlOutline); +} + +void SuppressorLookAndFeel::drawRotarySlider (juce::Graphics& graphics, int x, int y, + int width, int height, float sliderPosition, + float startAngle, float endAngle, + juce::Slider& slider) +{ + const auto size = static_cast (juce::jmin (width, height)); + const auto radius = size * 0.5f - 8.0f; + const auto centre = juce::Point ( + static_cast (x) + static_cast (width) * 0.5f, + static_cast (y) + static_cast (height) * 0.5f); + const auto angle = juce::jmap (sliderPosition, 0.0f, 1.0f, startAngle, endAngle); + const auto alpha = slider.isEnabled() ? 1.0f : 0.38f; + + juce::Path track; + track.addCentredArc (centre.x, centre.y, radius, radius, 0.0f, + startAngle, endAngle, true); + graphics.setColour (slider.findColour (juce::Slider::rotarySliderOutlineColourId) + .withMultipliedAlpha (alpha)); + graphics.strokePath (track, juce::PathStrokeType (4.0f, + juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + + if (sliderPosition > 0.0f) + { + juce::Path value; + value.addCentredArc (centre.x, centre.y, radius, radius, 0.0f, + startAngle, angle, true); + graphics.setColour (slider.findColour (juce::Slider::rotarySliderFillColourId) + .withMultipliedAlpha (alpha)); + graphics.strokePath (value, juce::PathStrokeType (4.0f, + juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + } + + const auto innerRadius = radius - 10.0f; + graphics.setColour (SuppressorTheme::surfaceRaised.withMultipliedAlpha (alpha)); + graphics.fillEllipse (centre.x - innerRadius, centre.y - innerRadius, + innerRadius * 2.0f, innerRadius * 2.0f); + + auto innerOutline = slider.isMouseOverOrDragging() + ? SuppressorTheme::secondaryText + : SuppressorTheme::divider; + graphics.setColour (innerOutline.withMultipliedAlpha (alpha)); + graphics.drawEllipse (centre.x - innerRadius, centre.y - innerRadius, + innerRadius * 2.0f, innerRadius * 2.0f, 1.0f); + + const auto markerStart = centre.getPointOnCircumference (innerRadius - 9.0f, angle); + const auto markerEnd = centre.getPointOnCircumference (innerRadius - 3.0f, angle); + graphics.setColour (slider.findColour (juce::Slider::rotarySliderFillColourId) + .withMultipliedAlpha (alpha)); + graphics.drawLine ({ markerStart, markerEnd }, 2.5f); + + if (slider.hasKeyboardFocus (true)) + { + graphics.setColour (SuppressorTheme::accent); + graphics.drawEllipse (centre.x - radius - 4.0f, centre.y - radius - 4.0f, + (radius + 4.0f) * 2.0f, (radius + 4.0f) * 2.0f, 2.0f); + } +} + +juce::Label* SuppressorLookAndFeel::createSliderTextBox (juce::Slider& slider) +{ + auto* label = juce::LookAndFeel_V4::createSliderTextBox (slider); + label->setFont (SuppressorTheme::makeFont (15.0f, true)); + label->setJustificationType (juce::Justification::centred); + label->onEditorShow = [label] + { + if (auto* editor = label->getCurrentTextEditor()) + { + editor->setFont (label->getFont()); + editor->setJustification (juce::Justification::centred); + } + }; + label->setColour (juce::Label::backgroundColourId, juce::Colours::transparentBlack); + label->setColour (juce::Label::backgroundWhenEditingColourId, juce::Colours::transparentBlack); + label->setColour (juce::Label::outlineColourId, juce::Colours::transparentBlack); + label->setColour (juce::Label::outlineWhenEditingColourId, juce::Colours::transparentBlack); + label->setColour (juce::TextEditor::backgroundColourId, juce::Colours::transparentBlack); + label->setColour (juce::TextEditor::outlineColourId, juce::Colours::transparentBlack); + label->setColour (juce::TextEditor::focusedOutlineColourId, juce::Colours::transparentBlack); + label->setColour (juce::TextEditor::shadowColourId, juce::Colours::transparentBlack); + label->setInterceptsMouseClicks (false, true); + return label; +} + +juce::Slider::SliderLayout SuppressorLookAndFeel::getSliderLayout (juce::Slider& slider) +{ + juce::Slider::SliderLayout layout; + const auto bounds = slider.getLocalBounds(); + layout.sliderBounds = bounds; + + if (slider.getTextBoxPosition() != juce::Slider::NoTextBox) + { + const auto width = juce::jmin (slider.getTextBoxWidth(), bounds.getWidth() - 4); + const auto height = juce::jmin (slider.getTextBoxHeight(), bounds.getHeight() - 4); + layout.textBoxBounds = juce::Rectangle (width, height).withCentre (bounds.getCentre()); + } + + return layout; +} + +void SuppressorLookAndFeel::drawComboBox (juce::Graphics& graphics, int width, int height, + bool isButtonDown, int, int, int, int, + juce::ComboBox& box) +{ + auto bounds = juce::Rectangle (0.5f, 0.5f, + static_cast (width) - 1.0f, + static_cast (height) - 1.0f); + graphics.setColour (SuppressorTheme::surfaceRaised); + graphics.fillRoundedRectangle (bounds, 6.0f); + + const auto focused = box.hasKeyboardFocus (false); + const auto outline = focused || isButtonDown + ? SuppressorTheme::accent + : (box.isMouseOver() ? SuppressorTheme::secondaryText + : SuppressorTheme::controlOutline); + graphics.setColour (outline); + graphics.drawRoundedRectangle (bounds, 6.0f, focused ? 2.0f : 1.0f); + + const auto arrowCentreX = static_cast (width - 17); + const auto arrowCentreY = static_cast (height) * 0.5f; + juce::Path arrow; + arrow.startNewSubPath (arrowCentreX - 4.0f, arrowCentreY - 2.0f); + arrow.lineTo (arrowCentreX, arrowCentreY + 2.0f); + arrow.lineTo (arrowCentreX + 4.0f, arrowCentreY - 2.0f); + graphics.setColour (focused ? SuppressorTheme::accent : SuppressorTheme::secondaryText); + graphics.strokePath (arrow, juce::PathStrokeType (1.7f, + juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); +} + +juce::Font SuppressorLookAndFeel::getComboBoxFont (juce::ComboBox&) +{ + return SuppressorTheme::makeFont (15.0f); +} + +juce::Font SuppressorLookAndFeel::getPopupMenuFont() +{ + return SuppressorTheme::makeFont (15.0f); +} + +juce::Rectangle SuppressorLookAndFeel::getTooltipBounds ( + const juce::String& text, juce::Point screenPosition, + juce::Rectangle parentArea) +{ + const auto layout = makeTooltipLayout (text, SuppressorTheme::primaryText, 300.0f); + const auto width = juce::roundToInt (std::ceil (layout.getWidth())) + 16; + const auto height = juce::roundToInt (std::ceil (layout.getHeight())) + 10; + return juce::Rectangle ( + screenPosition.x > parentArea.getCentreX() ? screenPosition.x - width - 12 + : screenPosition.x + 24, + screenPosition.y > parentArea.getCentreY() ? screenPosition.y - height - 6 + : screenPosition.y + 6, + width, height) + .constrainedWithin (parentArea); +} + +void SuppressorLookAndFeel::drawTooltip (juce::Graphics& graphics, + const juce::String& text, + int width, int height) +{ + const auto bounds = juce::Rectangle (width, height).toFloat(); + graphics.setColour (SuppressorTheme::surfaceRaised); + graphics.fillRoundedRectangle (bounds, 5.0f); + graphics.setColour (SuppressorTheme::controlOutline); + graphics.drawRoundedRectangle (bounds.reduced (0.5f), 5.0f, 1.0f); + + const auto textBounds = bounds.reduced (8.0f, 5.0f); + auto layout = makeTooltipLayout (text, SuppressorTheme::primaryText, + textBounds.getWidth()); + layout.draw (graphics, textBounds); +} + +void SuppressorLookAndFeel::positionComboBoxText (juce::ComboBox& box, juce::Label& label) +{ + label.setBounds (12, 1, box.getWidth() - 38, box.getHeight() - 2); + label.setFont (getComboBoxFont (box)); +} + +void SuppressorLookAndFeel::drawToggleButton (juce::Graphics& graphics, + juce::ToggleButton& button, + bool isMouseOver, bool isButtonDown) +{ + const auto alpha = button.isEnabled() ? 1.0f : 0.42f; + auto bounds = button.getLocalBounds().toFloat().reduced (0.5f); + auto fill = SuppressorTheme::surfaceRaised; + if (isButtonDown) + fill = fill.brighter (0.08f); + graphics.setColour (fill.withMultipliedAlpha (alpha)); + graphics.fillRoundedRectangle (bounds, 6.0f); + + const auto focused = button.hasKeyboardFocus (false); + const auto outline = focused ? SuppressorTheme::accent + : (isMouseOver ? SuppressorTheme::secondaryText + : SuppressorTheme::controlOutline); + graphics.setColour (outline.withMultipliedAlpha (alpha)); + graphics.drawRoundedRectangle (bounds, 6.0f, focused ? 2.0f : 1.0f); + + auto indicator = juce::Rectangle (12.0f, + static_cast (button.getHeight()) * 0.5f - 8.0f, + 16.0f, 16.0f); + graphics.setColour ((button.getToggleState() ? SuppressorTheme::accent + : SuppressorTheme::surface) + .withMultipliedAlpha (alpha)); + graphics.fillRoundedRectangle (indicator, 3.0f); + graphics.setColour ((button.getToggleState() ? SuppressorTheme::accent + : SuppressorTheme::controlOutline) + .withMultipliedAlpha (alpha)); + graphics.drawRoundedRectangle (indicator, 3.0f, 1.0f); + + if (button.getToggleState()) + { + juce::Path check; + check.startNewSubPath (indicator.getX() + 3.5f, indicator.getCentreY()); + check.lineTo (indicator.getX() + 7.0f, indicator.getBottom() - 4.0f); + check.lineTo (indicator.getRight() - 3.0f, indicator.getY() + 4.0f); + graphics.setColour (SuppressorTheme::canvas.withMultipliedAlpha (alpha)); + graphics.strokePath (check, juce::PathStrokeType (2.0f, + juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + } + + graphics.setColour (SuppressorTheme::primaryText.withMultipliedAlpha (alpha)); + graphics.setFont (SuppressorTheme::makeFont (14.5f, true, 0.02f)); + graphics.drawFittedText (button.getButtonText(), 38, 0, button.getWidth() - 48, + button.getHeight(), juce::Justification::centredLeft, 1); +} + +void SuppressorLookAndFeel::drawButtonBackground (juce::Graphics& graphics, + juce::Button& button, + const juce::Colour&, + bool isMouseOver, bool isButtonDown) +{ + const auto alpha = button.isEnabled() ? 1.0f : 0.42f; + const auto active = button.getToggleState(); + auto bounds = button.getLocalBounds().toFloat().reduced (0.5f); + auto fill = active ? SuppressorTheme::accent : SuppressorTheme::surfaceRaised; + if (isButtonDown) + fill = fill.brighter (active ? 0.04f : 0.08f); + graphics.setColour (fill.withMultipliedAlpha (alpha)); + graphics.fillRoundedRectangle (bounds, 6.0f); + + const auto focused = button.hasKeyboardFocus (false); + const auto outline = active ? SuppressorTheme::accent + : (isMouseOver ? SuppressorTheme::secondaryText + : SuppressorTheme::controlOutline); + graphics.setColour ((focused ? (active ? SuppressorTheme::primaryText + : SuppressorTheme::accent) + : outline) + .withMultipliedAlpha (alpha)); + graphics.drawRoundedRectangle (bounds, 6.0f, focused ? 2.0f : 1.0f); + if (focused && active) + { + graphics.setColour (SuppressorTheme::canvas.withMultipliedAlpha (alpha)); + graphics.drawRoundedRectangle (bounds.reduced (3.0f), 4.0f, 1.5f); + } +} + +void SuppressorLookAndFeel::drawButtonText (juce::Graphics& graphics, + juce::TextButton& button, + bool, bool) +{ + const auto alpha = button.isEnabled() ? 1.0f : 0.42f; + graphics.setColour ((button.getToggleState() ? SuppressorTheme::canvas + : SuppressorTheme::primaryText) + .withMultipliedAlpha (alpha)); + graphics.setFont (getTextButtonFont (button, button.getHeight())); + graphics.drawFittedText (button.getButtonText(), button.getLocalBounds().reduced (8, 2), + juce::Justification::centred, 1); +} + +juce::Font SuppressorLookAndFeel::getTextButtonFont (juce::TextButton&, int buttonHeight) +{ + return SuppressorTheme::makeFont (juce::jmin (15.0f, static_cast (buttonHeight) * 0.5f), + true, 0.04f); +} diff --git a/Source/PluginLookAndFeel.h b/Source/PluginLookAndFeel.h new file mode 100644 index 0000000..a9088b3 --- /dev/null +++ b/Source/PluginLookAndFeel.h @@ -0,0 +1,83 @@ +#pragma once + +#include + +#include +#include + +namespace SuppressorTheme +{ +inline const juce::Colour canvas { 0xff0a0e14 }; +inline const juce::Colour header { 0xff0d141e }; +inline const juce::Colour surface { 0xff111a24 }; +inline const juce::Colour surfaceRaised { 0xff182432 }; +inline const juce::Colour divider { 0xff2a3a4b }; +inline const juce::Colour controlOutline { 0xff66798c }; +inline const juce::Colour primaryText { 0xfff1f5f8 }; +inline const juce::Colour secondaryText { 0xffa6b2be }; +inline const juce::Colour accent { 0xff8ea7ff }; +inline const juce::Colour warning { 0xfff2b84b }; +inline const juce::Colour error { 0xffff6f7d }; +inline const juce::Colour inactiveControl { 0xff6c7c8f }; +inline constexpr float cornerRadius = 9.0f; + +juce::Font makeFont (float size, bool semibold = false, float tracking = 0.0f); +} + +class ParameterSlider final : public juce::Slider +{ +public: + using GestureCallback = std::function; + + void mouseDoubleClick (const juce::MouseEvent&) override; + bool keyPressed (const juce::KeyPress&) override; + double getValueFromText (const juce::String&) override; + void setKeyboardGestureCallbacks (GestureCallback begin, GestureCallback end); + void endActiveGesture(); + +private: + GestureCallback beginKeyboardGesture; + GestureCallback endKeyboardGesture; +}; + +class ParameterToggleButton final : public juce::ToggleButton +{ +public: + using juce::ToggleButton::ToggleButton; + bool keyPressed (const juce::KeyPress&) override; +}; + +class ParameterTextButton final : public juce::TextButton +{ +public: + using juce::TextButton::TextButton; + bool keyPressed (const juce::KeyPress&) override; +}; + +class SuppressorLookAndFeel final : public juce::LookAndFeel_V4 +{ +public: + SuppressorLookAndFeel(); + + void drawRotarySlider (juce::Graphics&, int x, int y, int width, int height, + float sliderPosition, float startAngle, float endAngle, + juce::Slider&) override; + juce::Label* createSliderTextBox (juce::Slider&) override; + juce::Slider::SliderLayout getSliderLayout (juce::Slider&) override; + void drawComboBox (juce::Graphics&, int width, int height, bool isButtonDown, + int buttonX, int buttonY, int buttonWidth, int buttonHeight, + juce::ComboBox&) override; + juce::Font getComboBoxFont (juce::ComboBox&) override; + juce::Font getPopupMenuFont() override; + juce::Rectangle getTooltipBounds (const juce::String&, juce::Point, + juce::Rectangle) override; + void drawTooltip (juce::Graphics&, const juce::String&, int width, int height) override; + void positionComboBoxText (juce::ComboBox&, juce::Label&) override; + void drawToggleButton (juce::Graphics&, juce::ToggleButton&, bool isMouseOver, + bool isButtonDown) override; + void drawButtonBackground (juce::Graphics&, juce::Button&, const juce::Colour&, + bool isMouseOver, bool isButtonDown) override; + void drawButtonText (juce::Graphics&, juce::TextButton&, bool isMouseOver, + bool isButtonDown) override; + juce::Font getTextButtonFont (juce::TextButton&, int buttonHeight) override; +}; diff --git a/Tests/test_ui.cpp b/Tests/test_ui.cpp new file mode 100644 index 0000000..666a473 --- /dev/null +++ b/Tests/test_ui.cpp @@ -0,0 +1,429 @@ +#include + +#include "PluginEditor.h" +#include "PluginLookAndFeel.h" + +#include +#include +#include +#include + +namespace +{ +float relativeLuminance (juce::Colour colour) +{ + auto linearise = [] (float value) + { + return value <= 0.04045f ? value / 12.92f + : std::pow ((value + 0.055f) / 1.055f, 2.4f); + }; + + return 0.2126f * linearise (colour.getFloatRed()) + + 0.7152f * linearise (colour.getFloatGreen()) + + 0.0722f * linearise (colour.getFloatBlue()); +} + +float contrastRatio (juce::Colour first, juce::Colour second) +{ + const auto lighter = std::max (relativeLuminance (first), relativeLuminance (second)); + const auto darker = std::min (relativeLuminance (first), relativeLuminance (second)); + return (lighter + 0.05f) / (darker + 0.05f); +} + +int countPixelsNear (juce::Image& image, juce::Colour target) +{ + int count = 0; + for (int y = 0; y < image.getHeight(); ++y) + for (int x = 0; x < image.getWidth(); ++x) + { + const auto pixel = image.getPixelAt (x, y); + const auto distance = std::abs (pixel.getFloatRed() - target.getFloatRed()) + + std::abs (pixel.getFloatGreen() - target.getFloatGreen()) + + std::abs (pixel.getFloatBlue() - target.getFloatBlue()); + if (distance < 0.08f) + ++count; + } + return count; +} + +juce::MouseEvent makeMouseEvent (juce::Component& target, juce::ModifierKeys modifiers, + int clicks) +{ + const auto now = juce::Time::getCurrentTime(); + return { juce::Desktop::getInstance().getMainMouseSource(), { 60.0f, 70.0f }, + modifiers, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, &target, &target, now, + { 60.0f, 70.0f }, now, clicks, false }; +} + +struct ParameterGestureCounter final : juce::AudioProcessorParameter::Listener +{ + void parameterValueChanged (int, float) override {} + void parameterGestureChanged (int, bool starting) override + { + starting ? ++starts : ++ends; + } + + void reset() + { + starts = 0; + ends = 0; + } + + int starts = 0; + int ends = 0; +}; + +} + +TEST_CASE ("theme colours meet text and essential-control contrast targets") +{ + CHECK (contrastRatio (SuppressorTheme::primaryText, SuppressorTheme::canvas) >= 4.5f); + CHECK (contrastRatio (SuppressorTheme::secondaryText, SuppressorTheme::surface) >= 4.5f); + CHECK (contrastRatio (SuppressorTheme::accent, SuppressorTheme::canvas) >= 4.5f); + CHECK (contrastRatio (SuppressorTheme::warning, SuppressorTheme::surface) >= 4.5f); + CHECK (contrastRatio (SuppressorTheme::error, SuppressorTheme::surface) >= 4.5f); + CHECK (contrastRatio (SuppressorTheme::controlOutline, + SuppressorTheme::surfaceRaised) >= 3.0f); +} + +TEST_CASE ("rotary value is represented by a proportional accent arc") +{ + SuppressorLookAndFeel lookAndFeel; + juce::Slider slider; + slider.setLookAndFeel (&lookAndFeel); + + auto render = [&] (float position) + { + juce::Image image (juce::Image::RGB, 120, 120, true); + juce::Graphics graphics (image); + lookAndFeel.drawRotarySlider (graphics, 10, 10, 100, 100, position, + juce::MathConstants::pi * 1.25f, + juce::MathConstants::pi * 2.75f, + slider); + return image; + }; + + auto minimum = render (0.0f); + auto maximum = render (1.0f); + CHECK (countPixelsNear (maximum, SuppressorTheme::accent) + > countPixelsNear (minimum, SuppressorTheme::accent) + 100); +} + +TEST_CASE ("rotary value field is centred and leaves the complete dial draggable") +{ + juce::ScopedJuceInitialiser_GUI gui; + SuppressorLookAndFeel lookAndFeel; + juce::Component parent; + ParameterSlider slider; + parent.setLookAndFeel (&lookAndFeel); + parent.setBounds (0, 0, 120, 140); + parent.addAndMakeVisible (slider); + slider.setBounds (parent.getLocalBounds()); + slider.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + slider.setTextBoxStyle (juce::Slider::TextBoxBelow, false, 80, 24); + + const auto layout = lookAndFeel.getSliderLayout (slider); + CHECK (layout.sliderBounds == slider.getLocalBounds()); + CHECK (layout.textBoxBounds.getCentre() == slider.getLocalBounds().getCentre()); + + auto* valueLabel = dynamic_cast (slider.getChildComponent (0)); + REQUIRE (valueLabel != nullptr); + bool labelInterceptsClicks = true; + bool editorInterceptsClicks = false; + valueLabel->getInterceptsMouseClicks (labelInterceptsClicks, editorInterceptsClicks); + CHECK_FALSE (labelInterceptsClicks); + CHECK (editorInterceptsClicks); + CHECK (slider.getComponentAt (layout.textBoxBounds.getCentre()) == &slider); +} + +TEST_CASE ("bundled display font is available in both weights") +{ + const auto regular = SuppressorTheme::makeFont (18.0f); + const auto semibold = SuppressorTheme::makeFont (18.0f, true); + CHECK (regular.getTypefaceName() == "Barlow Condensed"); + CHECK (semibold.getTypefaceName() == "Barlow Condensed"); + CHECK (regular.getTypefaceStyle() != semibold.getTypefaceStyle()); +} + +TEST_CASE ("double-click opens exact value entry instead of resetting") +{ + juce::ScopedJuceInitialiser_GUI gui; + SuppressorLookAndFeel lookAndFeel; + ParameterSlider slider; + slider.setLookAndFeel (&lookAndFeel); + slider.setBounds (0, 0, 120, 140); + slider.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + slider.setTextBoxStyle (juce::Slider::TextBoxBelow, false, 80, 24); + slider.setDoubleClickReturnValue (true, 0.5); + slider.setValue (0.8); + + auto event = makeMouseEvent (slider, + juce::ModifierKeys (juce::ModifierKeys::leftButtonModifier), + 2); + slider.mouseDoubleClick (event); + + auto* valueLabel = dynamic_cast (slider.getChildComponent (0)); + REQUIRE (valueLabel != nullptr); + CHECK (valueLabel->isBeingEdited()); + CHECK (slider.getValue() == doctest::Approx (0.8)); + slider.hideTextBox (true); +} + +TEST_CASE ("invalid exact-entry text preserves the current value") +{ + ParameterSlider slider; + slider.setRange (-80.0, 0.0, 0.1); + slider.setTextValueSuffix (" dB"); + slider.setValue (-40.0); + + CHECK (slider.getValueFromText ("not a number") == doctest::Approx (-40.0)); + CHECK (slider.getValueFromText ("--12") == doctest::Approx (-40.0)); + CHECK (slider.getValueFromText ("1e2") == doctest::Approx (-40.0)); + CHECK (slider.getValueFromText ("0x10") == doctest::Approx (-40.0)); + CHECK (slider.getValueFromText ("-12.5 garbage") == doctest::Approx (-40.0)); + CHECK (slider.getValueFromText ("-12.5 dB") == doctest::Approx (-12.5)); +} + +TEST_CASE ("Alt/Option-click remains the non-conflicting default reset gesture") +{ + struct DragCounter final : juce::Slider::Listener + { + void sliderValueChanged (juce::Slider*) override {} + void sliderDragStarted (juce::Slider*) override { ++starts; } + void sliderDragEnded (juce::Slider*) override { ++ends; } + + int starts = 0; + int ends = 0; + } counter; + + juce::ScopedJuceInitialiser_GUI gui; + ParameterSlider slider; + slider.addListener (&counter); + slider.setBounds (0, 0, 120, 140); + slider.setRange (0.0, 1.0, 0.01); + slider.setDoubleClickReturnValue (true, 0.25, juce::ModifierKeys::altModifier); + slider.setValue (0.8); + + auto event = makeMouseEvent ( + slider, + juce::ModifierKeys (juce::ModifierKeys::leftButtonModifier + | juce::ModifierKeys::altModifier), + 1); + slider.mouseDown (event); + CHECK (slider.getValue() == doctest::Approx (0.25)); + CHECK (counter.starts == 1); + CHECK (counter.ends == 1); + + auto dragEvent = makeMouseEvent ( + slider, juce::ModifierKeys (juce::ModifierKeys::leftButtonModifier), 1); + slider.mouseDown (dragEvent); + CHECK (counter.starts == 2); + CHECK (counter.ends == 1); + slider.endActiveGesture(); + CHECK (counter.ends == 2); + CHECK (slider.getThumbBeingDragged() == -1); +} + + +TEST_CASE ("editor integrates every parameter with truthful state and balanced gestures") +{ + juce::ScopedJuceInitialiser_GUI gui; + SuppressorProcessor processor; + auto editor = std::make_unique (processor); + + const juce::StringArray parameterIds { + "strength", "threshold", "release", "gateMode", "depth", "hysteresis", "hold", + "adaptiveRelease", "cue", "sidechain", "lookahead", "humEnable", "humBase", + "humHarmonics", "humStrength", "humLearn", "bandMode", "bandsLearn", + "deltaAudition", "outputGain" + }; + + CHECK (editor->getWidth() == 1000); + CHECK (editor->getHeight() == 640); + CHECK_FALSE (editor->isResizable()); + REQUIRE (editor->getNumChildComponents() > 0); + CHECK (editor->getChildComponent (0)->getName() == "Suppression activity"); + CHECK (editor->getChildComponent (0)->getExplicitFocusOrder() == 1); + CHECK (processor.getParameters().size() == parameterIds.size()); + + std::set focusOrders; + std::set accessibleNames; + for (const auto& id : parameterIds) + { + CAPTURE (id); + auto* component = editor->findChildWithID (id); + auto* parameter = processor.apvts.getParameter (id); + REQUIRE (component != nullptr); + REQUIRE (parameter != nullptr); + + const auto parameterIndex = processor.getParameters().indexOf (parameter); + CHECK (editor->getControlParameterIndex (*component) == parameterIndex); + CHECK (component->isVisible()); + CHECK (component->getWidth() >= 32); + CHECK (component->getHeight() >= 32); + CHECK (component->getWantsKeyboardFocus()); + CHECK (component->getExplicitFocusOrder() > 0); + CHECK (focusOrders.insert (component->getExplicitFocusOrder()).second); + CHECK (component->getName().isNotEmpty()); + CHECK (accessibleNames.insert (component->getName().toStdString()).second); + + if (auto* slider = dynamic_cast (component)) + { + CHECK (slider->getTextFromValue (slider->getValue()).isNotEmpty()); + REQUIRE (slider->getNumChildComponents() > 0); + CHECK (editor->getControlParameterIndex (*slider->getChildComponent (0)) + == parameterIndex); + } + } + CHECK (focusOrders.size() == 20); + CHECK (accessibleNames.size() == 20); + + auto* strength = dynamic_cast (editor->findChildWithID ("strength")); + auto* strengthParameter = processor.apvts.getParameter ("strength"); + REQUIRE (strength != nullptr); + REQUIRE (strengthParameter != nullptr); + CHECK (strength->getValueFromText ("50 %") == doctest::Approx (0.5)); + CHECK (strength->getValueFromText ("50 garbage") + == doctest::Approx (strength->getValue())); + CHECK (strength->getValueFromText ("1e2") + == doctest::Approx (strength->getValue())); + + ParameterGestureCounter gestures; + strengthParameter->addListener (&gestures); + strength->setValue (0.2, juce::sendNotificationSync); + gestures.reset(); + CHECK (strength->keyPressed (juce::KeyPress (juce::KeyPress::rightKey))); + CHECK (gestures.starts == 1); + CHECK (gestures.ends == 1); + + strength->setValue (0.2, juce::sendNotificationSync); + gestures.reset(); + auto resetEvent = makeMouseEvent ( + *strength, + juce::ModifierKeys (juce::ModifierKeys::leftButtonModifier + | juce::ModifierKeys::altModifier), + 1); + strength->mouseDown (resetEvent); + CHECK (strength->getValue() == doctest::Approx (0.9)); + CHECK (gestures.starts == 1); + CHECK (gestures.ends == 1); + + auto* adaptive = dynamic_cast ( + editor->findChildWithID ("adaptiveRelease")); + auto* adaptiveParameter = processor.apvts.getParameter ("adaptiveRelease"); + REQUIRE (adaptive != nullptr); + REQUIRE (adaptiveParameter != nullptr); + ParameterGestureCounter buttonGestures; + adaptiveParameter->addListener (&buttonGestures); + CHECK (adaptive->keyPressed (juce::KeyPress (' '))); + CHECK (adaptive->getToggleState()); + CHECK (buttonGestures.starts == 1); + CHECK (buttonGestures.ends == 1); + adaptiveParameter->removeListener (&buttonGestures); + + auto* bandMode = dynamic_cast (editor->findChildWithID ("bandMode")); + auto* bandsLearn = dynamic_cast ( + editor->findChildWithID ("bandsLearn")); + auto* bandsLearnParameter = processor.apvts.getParameter ("bandsLearn"); + REQUIRE (bandMode != nullptr); + REQUIRE (bandsLearn != nullptr); + REQUIRE (bandsLearnParameter != nullptr); + ParameterGestureCounter learnGestures; + bandsLearnParameter->addListener (&learnGestures); + bandMode->setSelectedItemIndex (1, juce::sendNotificationSync); + CHECK (bandsLearn->keyPressed (juce::KeyPress (' '))); + CHECK (bandsLearn->getToggleState()); + CHECK (bandsLearnParameter->getValue() == doctest::Approx (1.0f)); + CHECK (learnGestures.starts == 1); + CHECK (learnGestures.ends == 1); + bandsLearnParameter->removeListener (&learnGestures); + bandMode->setSelectedItemIndex (2, juce::sendNotificationSync); + + juce::Component* activity = nullptr; + juce::Label* bandsStatus = nullptr; + for (auto* child : editor->getChildren()) + { + if (child->getName() == "Suppression activity") + activity = child; + if (child->getName() == "Multiband learning status") + bandsStatus = dynamic_cast (child); + } + REQUIRE (activity != nullptr); + REQUIRE (bandsStatus != nullptr); + CHECK (activity->getDescription().containsIgnoreCase ("learn armed")); + CHECK (activity->getDescription().containsIgnoreCase ("gain reduction unavailable")); + CHECK (activity->getDescription().containsIgnoreCase ("passes while processing")); + CHECK (bandsStatus->getText().containsIgnoreCase ("armed")); + CHECK_FALSE (bandsStatus->getText().containsIgnoreCase ("capturing")); + + auto* delta = dynamic_cast (editor->findChildWithID ("deltaAudition")); + REQUIRE (delta != nullptr); + delta->setToggleState (true, juce::sendNotificationSync); + bandMode->setSelectedItemIndex (1, juce::sendNotificationSync); + CHECK (activity->getDescription().containsIgnoreCase ("output: removed signal")); + + gestures.reset(); + { + auto dragEvent = makeMouseEvent ( + *strength, juce::ModifierKeys (juce::ModifierKeys::leftButtonModifier), 1); + strength->mouseDown (dragEvent); + } + CHECK (gestures.starts == 1); + CHECK (gestures.ends == 0); + editor.reset(); + CHECK (gestures.ends == 1); + strengthParameter->removeListener (&gestures); +} + +TEST_CASE ("declared host scale factors preserve logical layout and render cleanly") +{ + juce::ScopedJuceInitialiser_GUI gui; + const std::array scales { 1.0f, 1.25f, 1.5f, 1.75f, 2.0f }; + + for (const auto scale : scales) + { + CAPTURE (scale); + SuppressorProcessor processor; + auto editor = std::make_unique (processor); + const auto width = juce::roundToInt (1000.0f * scale); + const auto height = juce::roundToInt (640.0f * scale); + editor->setVisible (true); + editor->setScaleFactor (scale); + + CHECK (editor->getWidth() == 1000); + CHECK (editor->getHeight() == 640); + const auto transformedBounds = editor->getLocalBounds().toFloat().transformedBy ( + editor->getTransform()); + CHECK (transformedBounds.getWidth() == doctest::Approx (width)); + CHECK (transformedBounds.getHeight() == doctest::Approx (height)); + + juce::Image image (juce::Image::ARGB, width, height, true); + { + juce::Graphics graphics (image); + graphics.addTransform (juce::AffineTransform::scale (scale)); + editor->paintEntireComponent (graphics, true); + } + CHECK (image.getPixelAt (width - 2, height - 2).getAlpha() == 0xff); + CHECK (countPixelsNear (image, SuppressorTheme::accent) + > juce::roundToInt (100.0f * scale * scale)); + + const auto captureDirectory = juce::SystemStats::getEnvironmentVariable ( + "SUPPRESSOR_UI_CAPTURE_DIR", {}); + auto captureSucceeded = true; + if (captureDirectory.isNotEmpty()) + { + const auto directory = juce::File (captureDirectory); + captureSucceeded = directory.createDirectory().wasOk(); + auto output = directory.getChildFile ( + "suppressor-ui-" + juce::String (juce::roundToInt (scale * 100.0f)) + + ".png"); + juce::PNGImageFormat format; + auto stream = output.createOutputStream(); + captureSucceeded = captureSucceeded && stream != nullptr; + if (stream != nullptr) + captureSucceeded = captureSucceeded + && format.writeImageToStream (image, *stream); + } + CHECK (captureSucceeded); + } +} diff --git a/assets/fonts/BarlowCondensed-Regular.ttf b/assets/fonts/BarlowCondensed-Regular.ttf new file mode 100644 index 0000000..4cc0bfa Binary files /dev/null and b/assets/fonts/BarlowCondensed-Regular.ttf differ diff --git a/assets/fonts/BarlowCondensed-SemiBold.ttf b/assets/fonts/BarlowCondensed-SemiBold.ttf new file mode 100644 index 0000000..17dc36f Binary files /dev/null and b/assets/fonts/BarlowCondensed-SemiBold.ttf differ diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000..11f2528 --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/README.md b/assets/fonts/README.md new file mode 100644 index 0000000..90e7f00 --- /dev/null +++ b/assets/fonts/README.md @@ -0,0 +1,7 @@ +# Barlow Condensed + +This directory contains Barlow Condensed Regular and SemiBold, version 1.422. + +- Source: https://github.com/jpt/barlow/tree/1.422 +- Copyright: The Barlow Project Authors +- License: SIL Open Font License 1.1; see `OFL.txt`. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md new file mode 100644 index 0000000..623ced2 --- /dev/null +++ b/docs/UI_DESIGN.md @@ -0,0 +1,209 @@ +# Suppressor UI design brief + +This brief applies the HDN Plugin UI Design skill v0.1.1 to Suppressor 0.1.0. It documents the editor contract and does not modify the central design system. + +## Product + +- **Purpose:** Zero-latency-by-default, frequency-split noise suppression for guitar DI before high-gain amplification. +- **Primary musician task:** Remove high-band noise between notes without sacrificing body, sustain, or pick attack, then verify the removed signal when needed. +- **Character:** Surgical, quiet, fast, spectral, trustworthy. +- **Users:** Guitarists and engineers tracking or reamping mono/stereo DI, optionally with an external detector key. +- **Three-second read:** Product identity; active topology; suppression activity; Strength, Threshold, and Release; Delta Audition state. +- **Branding:** The existing Suppressor Audio identity is retained. The HDN grammar is used without adding an unapproved HDN product mark. + +## Delivery environment + +- **Framework:** JUCE 8.0.9. +- **CLAP wrapper:** clap-juce-extensions `c1a5ad025f95d01e03267857fa8276ebeed16500`. +- **Release formats:** VST3 and CLAP. +- **Design/test harness:** Standalone; it is not packaged by the current release workflow. +- **Platforms:** macOS, Windows, and Linux. +- **Editor policy:** Fixed at 1000 x 640 logical pixels. +- **Scale factors:** 100%, 125%, 150%, 175%, and 200% where the host/platform supports them. +- **Input:** Mouse, trackpad, keyboard, and assistive technology. +- **Typography:** Bundled Barlow Condensed 1.422 Regular and SemiBold under the SIL Open Font License 1.1. + +## Immutable functional contract + +The UI work must preserve: + +- every file under `Source/DSP/` and all audio-thread behavior; +- `PluginProcessor.*`, the APVTS state type `Parameters`, state serialization, and session compatibility; +- manufacturer/plugin codes `SpAu`/`Supr` and CLAP ID `audio.suppressor.suppressor`; +- all 20 parameter IDs, version hints, order, names, types, ranges, defaults, tapers, steps, units, text conversion, and automation behavior; +- buses, sidechain behavior, MIDI declarations, latency reporting, and wrapper identity; +- CLAP event resolution and wrapper revision. + +## Parameter inventory + +| ID | Purpose | Range / default / text | Dependency | +|---|---|---|---| +| `strength` | One-split crossover strength | 0–100%, 90%, linear | Inactive but editable in 4/6-Band | +| `threshold` | One-split detector threshold | -80–0 dB, -40 dB, 0.1 dB | Inactive but editable in 4/6-Band | +| `release` | Gate release | 2–30 ms, 6 ms, exponential | Active in every topology | +| `gateMode` | Reduction or Tight Gate | Reduction / Tight Gate, Reduction | Depth is ignored by Tight Gate | +| `depth` | Reduction-mode floor | 0–60 dB, 40 dB, 0.1 dB | Inactive but editable in Tight Gate | +| `hysteresis` | Open/close threshold separation | 0–24 dB, 6 dB, 0.1 dB | Active | +| `hold` | Retriggerable hold | 0–50 ms, 2 ms, 0.1 ms | Active | +| `adaptiveRelease` | Crest-dependent release | Off / on, off | Active | +| `cue` | Broadband transient cue | 0–100%, 0%, integer text | Active | +| `sidechain` | Detector source | Internal / External Key, Internal | External selection does not prove key presence | +| `lookahead` | Detector lookahead | 0 / 1 / 2 ms, 0 ms | Changes reported latency | +| `humEnable` | Enable learned hum dips | Off / on, off | Lock is also required for audible removal | +| `humBase` | Auto/50/60 Hz family | Auto / 50 Hz / 60 Hz, Auto | A change invalidates an existing lock | +| `humHarmonics` | Maximum hum harmonics | 1–16, 8, integer | Inactive but editable while Hum Removal is off | +| `humStrength` | Learned dip strength | 0–100%, 100% | Inactive but editable while Hum Removal is off | +| `humLearn` | Start/stop hum capture | Boolean, off | Works while Hum Removal is off; hum stage passes during capture | +| `bandMode` | Suppression topology | One-Split / 4-Band / 6-Band, One-Split | Determines core applicability | +| `bandsLearn` | Start/stop band threshold capture | Boolean, off | Editable/pre-armable in One-Split; capture is effective only while processing in 4/6-Band, where the suppression stage passes | +| `deltaAudition` | Output removed signal | Off / on, off | Critical output state | +| `outputGain` | Final gain | -12–12 dB, 0 dB, 0.1 dB | Active | + +All parameters retain version hint 1 and their current automatable state. + +## Mode and state inventory + +| State | Presentation | +|---|---| +| Reduction | Depth uses the periwinkle active arc. | +| Tight Gate | Depth remains operable in place with a neutral arc and an accessible “inactive but editable” explanation. | +| One-Split | Strength, global Threshold, and detector/threshold activity are active. | +| 4/6-Band | Strength and global Threshold remain in place with neutral arcs. The stale one-split detector value is replaced by “Global level not exposed”; gain reduction remains visible. | +| Hum off | Harmonics and Hum Strength remain editable with neutral arcs. | +| Hum learning armed | Learn becomes “Stop Learning”; status says the request is armed and capture/pass-through occurs only while processing. No lock-success claim is made. | +| Multiband learning armed | Learn becomes “Stop Learning”; Activity marks gain reduction unavailable and says suppression passes while processing. No lock-success claim is made. | +| One-Split + Bands Learn | The parameter remains editable and can be armed for automation/preconfiguration; status says it is waiting for 4/6-Band. | +| Delta Audition | Activity status explicitly reads “OUTPUT: REMOVED SIGNAL” in addition to the selected Delta Audition control. | +| External Key | The selected source is shown without claiming that a sidechain is connected. | + +The processor does not expose bypass, key-presence, lock validity, learn completion, or a processing heartbeat through a safe immutable UI snapshot. The editor therefore makes no claims about those states. It labels activity as a last-processing snapshot, treats enabled Learn parameters as armed requests, and does not read the existing non-atomic engine status accessors. + +## Telemetry contract + +| Datum | Source | Decision | Validity and presentation | +|---|---|---|---| +| Detector level | `detectorDbAtomic` | Place the One-Split threshold relative to incoming high-band energy | Numeric and bar in One-Split; not shown as current in 4/6-Band because the source is stale there; <= -120 dB is “No Signal” | +| Gain reduction | `grDbAtomic` | See how much high-band attenuation is being applied | Positive magnitude, 0–60 dB display range; `60+ dB` avoids false precision in Tight Gate | +| Threshold marker | Existing `threshold` parameter | Compare detector level with the configured threshold | White marker on the detector bar in One-Split only | +| Learn armed | APVTS-backed Learn button state | Know that capture is requested and stop it | Text plus latched shape; capture/pass-through is explicitly conditional on processing; no invented progress or lock-success claim | + +The display repaints only when rounded values or semantic state changes and announces semantic transitions rather than meter samples. + +## Information architecture + +The dominant path is header → Suppression Core → Activity → Detection/Hum/Multiband and Output. + +- **Suppression Core:** A wide primary panel contains Gate Mode, Adaptive Release, and the six controls that determine the core suppression behavior. This prioritizes the main musical decision rather than mirroring implementation classes. +- **Activity:** The first accessibility group and a dedicated top-level panel present the last processing snapshot, with stale one-split data and learning pass-through handled explicitly rather than through a decorative analyzer. +- **Detection:** Cue, source, and lookahead stay together. +- **Hum Filter:** Enable, mains family, harmonics, strength, Learn, and capture state form one stable zone. +- **Multiband / Output:** Topology and learning share a compact upper zone; final gain and the critical Delta state sit below a divider. + +No mode moves a control. Spacing and surface hierarchy replace the previous six equal GroupComponent boxes. + +## Family system and tokens + +The implementation retains the family’s flat surfaces, bundled typography, centered rotary values, 4 px rhythm, 18 px outer margin, 12 px gaps, semantic focus, and exact-entry behavior. It intentionally replaces the Ring Modulator lime with a product-specific **spectral periwinkle** system: + +| Role | Token | Contrast evidence | +|---|---:|---| +| Canvas | `#0A0E14` | — | +| Header | `#0D141E` | — | +| Panel | `#111A24` | — | +| Raised control | `#182432` | — | +| Decorative divider | `#2A3A4B` | Not an interactive boundary | +| Essential control outline | `#66798C` | 3.5:1 on raised controls | +| Primary text | `#F1F5F8` | 16.0:1 on panels | +| Secondary text | `#A6B2BE` | 8.1:1 on panels | +| Active/focus accent | `#8EA7FF` | 7.6:1 on panels | +| Inactive-editable track | `#6C7C8F` | 4.1:1 on panels | +| Warning/acquiring | `#F2B84B` | 9.8:1 on panels | +| Error | `#FF6F7D` | 6.5:1 on panels | + +Color is paired with text, fill extent, marker geometry, a checkmark, or a persistent 2 px focus outline. + +## Interaction matrix + +| Control | Pointer | Fine / keyboard | Exact entry | Reset / wheel | Focus | +|---|---|---|---|---|---| +| Rotary | Full dial, centered value, arc, and interior drag; up/right increases | Shift-drag uses velocity fine mode; arrows retain JUCE parameter steps | Double-click anywhere or Return; Return commits, Escape cancels; invalid text preserves the current value | Alt/Option-click restores the APVTS default; wheel disabled | Explicit tab order and 2 px outer ring | +| Selector | Full 34 px control opens | Arrows navigate; Return selects; Escape dismisses | n/a | Host parameter mapping retained | 2 px outline plus arrow color/shape | +| Toggle | Full 34–38 px tile | Space toggles | n/a | Host-notifying ButtonAttachment | 2 px outline plus checkmark | +| Learn action | Full 36 px action | Space/Return operates | n/a | APVTS ButtonAttachment supplies host gesture/state sync | Unique Learn/Stop Learning name and latched fill/text; armed is not mislabeled as capturing | + +`getControlParameterIndex` maps each control and centered value child to its existing processor parameter for host services. + +## Accessibility + +- Every interactive component has a unique name, title, formatted parameter value/unit, description, and explicit focus order. +- Decorative labels are hidden from the accessibility tree to prevent duplicate announcements. +- All interactive targets are at least 34 px high; rotary targets are substantially larger. +- Inactive-but-editable controls remain enabled and explain why they currently have no audible effect. +- Critical states use text plus geometry; no state relies on color alone. +- The Activity display comes before controls in accessibility browse order and exposes a read-only last-snapshot value. Meter samples are queryable, but only semantic transitions announce. +- `EDITOR_WANTS_KEYBOARD_FOCUS` is enabled; embedded VST3 and CLAP focus and text-entry checks pass. + +## Host-format validation + +| Format | Artifact / validator | Manual host evidence | Result | +|---|---|---|---| +| VST3 | Release `suppressor.vst3`; pluginval 1.0.4 strictness 10 | JUCE 8.0.9 AudioPluginHost: embedded exact commit/cancel/invalid preservation, Tab and arrow paths, Alt/Option reset, normal and Shift-fine drag, host graph state save/recall, close/reopen, fixed-size rejection, and graceful null-context fallback | Pass | +| CLAP | Release `suppressor.clap`; clap-validator 0.4.1 | Pinned-header Cocoa integration host: 1000 x 640 logical size, non-resizable contract, 24 named accessibility elements, Tab/Space, exact entry, reset, normal/fine drag, balanced begin/value/end events, host-driven `params.flush` UI update, and repeated creation | Pass with wrapper debt noted below | +| Standalone | Release `suppressor.app` | Design/accessibility harness: complete state captures, AX inspection, pointer/keyboard paths, close-during-drag coverage, and fixed geometry | Pass; not a shipped format | + +VST3 and CLAP are treated as separate wrappers. Validator results supplement rather than replace the embedded-editor checks. Cocoa CLAP sizes are logical and the pinned CLAP contract does not permit `set_scale` for that native API. + +## Verification evidence + +### Captured states + +- [Default CLAP editor](images/suppressor-ui.png) +- [Multiband learning armed](images/suppressor-learning.png) +- [Persistent keyboard focus during learning](images/suppressor-learning-focus.png) +- [Delta Audition with `OUTPUT: REMOVED SIGNAL`](images/suppressor-delta-audition.png) +- [Centered exact-value entry](images/suppressor-exact-entry.png) +- [VST3 editor embedded in JUCE AudioPluginHost](images/suppressor-vst3-host.png) +- Scale renders: [100%](images/suppressor-scale-100.png), [125%](images/suppressor-scale-125.png), [150%](images/suppressor-scale-150.png), [175%](images/suppressor-scale-175.png), and [200%](images/suppressor-scale-200.png) + +### Automated and manual checks + +- Nine production-linked UI unit/integration cases pass with 363 assertions. They cover the bundled fonts, contrast, centered value hit testing, strict exact parsing, default reset, all 20 APVTS controls, accessible names and parameter mapping, truthful topology/learning/Delta states, keyboard gestures including Space, close-during-drag teardown, fixed logical geometry, and complete rendering at every declared scale. +- The existing DSP suite passes unchanged. +- Release VST3, CLAP, and Standalone targets build successfully. +- pluginval 1.0.4 at strictness 10 reports `SUCCESS`. +- clap-validator 0.4.1 reports 32 passed, 0 failed, one warning, and 11 skipped. +- The generated VST3 definition contains `JucePlugin_EditorRequiresKeyboardFocus=1`. +- In both hosted formats, Strength exact entry commits `20%`; Escape and malformed suffix text preserve the prior value; Right changes `20%` to `21%`; Alt/Option resets to `90%`; a normal 10 px drag changes `50%` to `54.5%`; and a Shift 50 px drag changes it to `50.2%`. +- CLAP output records exactly balanced begin/value/end events for Space, exact entry, arrows, reset, and drag. A host-originated `params.flush` change to `42%` updates the visible control without a feedback loop. +- VST3 host graph save/load restores Strength from `72%` to the saved `33%`, and editor close/reopen preserves `20%`. +- The fixed editor rejects host-window resize attempts in VST3 and CLAP. The scale test keeps 1000 x 640 logical bounds while rendering complete transformed bounds at 100%, 125%, 150%, 175%, and 200%. +- CI runs the UI render suite and uploads all declared-scale PNGs on macOS, Windows, and Linux; Linux uses Xvfb. CI also validates both shipped formats on all three platforms. + +## HDN review scorecard + +| Dimension | Score | Evidence / debt | +|---|---:|---| +| Truth and state | 2/2 | Last-snapshot, armed-request, conditional pass-through, unavailable reduction, and Delta output wording match exposed state. | +| Hierarchy and grouping | 2/2 | Stable topology and a clear core/activity/supporting-zone order. | +| Restraint and family fit | 2/2 | Flat technical surfaces and product-specific periwinkle without analyzer theater or unapproved branding. | +| Control and interaction clarity | 2/2 | Full targets, units, exact/reset/fine paths, focus, and neutral inactive-but-editable styling are explicit. | +| Typography and scaling | 2/2 | Bundled fonts and complete 100–200% scale renders have no clipping, crowding, or jitter. | +| Accessibility | 2/2 | Contrast, 20 unique controls, semantic Activity-first browse order, full keyboard paths, non-color cues, and persistent focus geometry pass. | +| Host correctness and performance | 2/2 | Separate embedded VST3/CLAP passes, balanced gestures, host synchronization/state recall, fixed sizing, bounded timers, validators, and immutable processor/DSP audit pass. | +| Polish and consistency | 2/2 | Alignment, rhythm, formatting, state styling, and interaction feedback form one coherent system. | +| **Total** | **16/16** | **SHIP for the UI boundary.** Pre-existing release metadata/signing debt remains outside this patch. | + +## Pre-existing release and wrapper debt + +These findings reproduce without the UI patch and are not silently broadened into this editor-only change: + +1. JUCE derives `com.Suppressor Audio.Suppressor`, whose space makes the macOS VST3 bundle identifier invalid for notarized release. Product/manufacturer codes and the CLAP ID remain intentionally unchanged here. +2. Baseline and final macOS CLAP/Standalone bundles report `code has no resources but signature indicates they must be present`. Standalone is not packaged; release signing remains an owner follow-up. +3. clap-validator's random-state warning and the pinned wrapper's choice/integer metadata behavior are unchanged wrapper debt; deterministic state and UI synchronization checks pass. +4. The pinned CLAP wrapper reports `gui.hide == false` for Cocoa while `show`, host-window concealment, destruction, and fresh creation operate. This is recorded as wrapper behavior rather than presented as a UI state. + +## Open constraints and design-system candidates + +1. Safe lock/base/notch and band-lock snapshots require a separate processor transport change, which is outside this UI-only boundary. +2. Host bypass and external-key validity are not exposed; the UI does not invent them. +3. Product-specific accent variation and the neutral “owned by another topology” treatment are candidates for the central design system only after review across more plugins. diff --git a/docs/images/suppressor-delta-audition.png b/docs/images/suppressor-delta-audition.png new file mode 100644 index 0000000..7329159 Binary files /dev/null and b/docs/images/suppressor-delta-audition.png differ diff --git a/docs/images/suppressor-exact-entry.png b/docs/images/suppressor-exact-entry.png new file mode 100644 index 0000000..c298e32 Binary files /dev/null and b/docs/images/suppressor-exact-entry.png differ diff --git a/docs/images/suppressor-learning-focus.png b/docs/images/suppressor-learning-focus.png new file mode 100644 index 0000000..4a1d924 Binary files /dev/null and b/docs/images/suppressor-learning-focus.png differ diff --git a/docs/images/suppressor-learning.png b/docs/images/suppressor-learning.png new file mode 100644 index 0000000..7090b85 Binary files /dev/null and b/docs/images/suppressor-learning.png differ diff --git a/docs/images/suppressor-scale-100.png b/docs/images/suppressor-scale-100.png new file mode 100644 index 0000000..6c0ee39 Binary files /dev/null and b/docs/images/suppressor-scale-100.png differ diff --git a/docs/images/suppressor-scale-125.png b/docs/images/suppressor-scale-125.png new file mode 100644 index 0000000..385eebb Binary files /dev/null and b/docs/images/suppressor-scale-125.png differ diff --git a/docs/images/suppressor-scale-150.png b/docs/images/suppressor-scale-150.png new file mode 100644 index 0000000..7918ab9 Binary files /dev/null and b/docs/images/suppressor-scale-150.png differ diff --git a/docs/images/suppressor-scale-175.png b/docs/images/suppressor-scale-175.png new file mode 100644 index 0000000..cc69641 Binary files /dev/null and b/docs/images/suppressor-scale-175.png differ diff --git a/docs/images/suppressor-scale-200.png b/docs/images/suppressor-scale-200.png new file mode 100644 index 0000000..c00d73d Binary files /dev/null and b/docs/images/suppressor-scale-200.png differ diff --git a/docs/images/suppressor-ui.png b/docs/images/suppressor-ui.png new file mode 100644 index 0000000..17e2614 Binary files /dev/null and b/docs/images/suppressor-ui.png differ diff --git a/docs/images/suppressor-vst3-host.png b/docs/images/suppressor-vst3-host.png new file mode 100644 index 0000000..e139626 Binary files /dev/null and b/docs/images/suppressor-vst3-host.png differ