diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 356777d..90bad33 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 xvfb \ + sudo apt-get install -y ninja-build pkg-config xvfb openbox x11-utils gdb \ libasound2-dev libx11-dev libxext-dev libxinerama-dev libxrandr-dev \ libxcursor-dev libgl1-mesa-dev libfreetype6-dev libfontconfig1-dev @@ -92,12 +92,42 @@ jobs: run: | mkdir -p ui-captures if [ "${{ runner.os }}" = "Windows" ]; then - export SUPPRESSOR_UI_CAPTURE_DIR="$(pwd -W)/ui-captures" + SUPPRESSOR_UI_CAPTURE_DIR="$(pwd -W)/ui-captures" else - export SUPPRESSOR_UI_CAPTURE_DIR="$PWD/ui-captures" + SUPPRESSOR_UI_CAPTURE_DIR="$PWD/ui-captures" fi + export SUPPRESSOR_UI_CAPTURE_DIR if [ "${{ runner.os }}" = "Linux" ]; then - xvfb-run -a ctest --test-dir build --output-on-failure -C Release -R suppressor-ui-unit + # Native JUCE peers need WM_PROTOCOLS and other window-manager atoms, + # which a bare Xvfb server does not supply. Variables below belong to + # the inner shell running on that display. + # shellcheck disable=SC2016 + xvfb-run -a sh -c ' + openbox --sm-disable >/tmp/suppressor-openbox.log 2>&1 & + wm_pid=$! + trap "kill $wm_pid 2>/dev/null || true" EXIT + ready=false + for attempt in $(seq 1 50); do + if xprop -root _NET_SUPPORTING_WM_CHECK | grep -q "window id"; then + ready=true + break + fi + sleep 0.1 + done + if [ "$ready" != true ]; then + echo "Window manager did not start" + exit 1 + fi + if ! ctest --test-dir build --output-on-failure -C Release -R suppressor-ui-unit; then + # Native X11 errors terminate the process rather than fail an + # assertion. Collect a stack while keeping the test gate failed. + export SUPPRESSOR_VST3_PATH="$PWD/build/Source/Suppressor_artefacts/Release/VST3/suppressor.vst3" + export SUPPRESSOR_CLAP_PATH="$PWD/build/Source/Suppressor_artefacts/Release/CLAP/suppressor.clap" + gdb --batch -ex "set breakpoint pending on" -ex "break _XDefaultError" \ + -ex run -ex bt --args build/Source/SuppressorUITests --success || true + exit 1 + fi + ' else ctest --test-dir build --output-on-failure -C Release -R suppressor-ui-unit fi @@ -233,7 +263,7 @@ jobs: for d in *.vst3; do [ -e "$d" ] && zip -qr "../suppressor-${short}-vst3.zip" "$d"; done && \ for f in *.clap; do [ -e "$f" ] && zip -qr "../suppressor-${short}-clap.zip" "$f"; done) done - ls -la *.zip + ls -la -- *.zip - uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index 5dd4f69..1eee6e5 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ high-gain amp-sim and reamping chains. It sits on the raw DI *before* the amp sim, strips pickup-borne hiss/buzz/EMI between notes, and leaves the guitar 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) +[![CI](https://github.com/affinedsp/suppressor/actions/workflows/ci.yml/badge.svg)](https://github.com/affinedsp/suppressor/actions/workflows/ci.yml)

- Suppressor plugin interface + Suppressor: three essential knobs, input/output peak meters, and a clearly labelled high-band reduction display

## What it does @@ -30,14 +30,28 @@ biquads plus an envelope per channel). ## Controls -| Section | Controls | +| Control | What it does | |---|---| -| **Core** | Strength (crossover 8 kHz → 1.4 kHz), Threshold, Release (2–30 ms, exponential) | -| **Gate** | Reduction / Tight Gate mode, Depth floor, Hysteresis, Hold, Adaptive Release | -| **Detection** | Transient Cue, internal/external detector source, 0/1/2 ms lookahead | -| **Hum Removal** | Learn-and-lock resonant dips on the 50/60 Hz family, strength, harmonic count | -| **Multiband** | Optional 4/6-band learned-threshold mode (advanced; one-split is the default) | -| **Output** | Delta (removed-signal) audition, output gain, detector/GR metering | +| **Threshold** | Sets when highs are reduced. Raise it until noise between notes is suppressed. | +| **Strength** | Sets how much of the high-frequency range is treated (8 kHz → 1.4 kHz). Zero is not bypass. | +| **Release** | Sets how quickly suppression returns after a note (2–30 ms). Increase it to preserve tails. | +| **Listen removed** | Auditions input minus processed audio; switch it off to hear the processed signal. This difference includes filter phase effects, not just noise. | + +Input and output meters show **peak dBFS**. The large readout shows +**high-band gain reduction**, not whole-signal loudness loss; in multiband +sessions it shows the deepest band reduction. Clip warnings, bypass, silence, +learning, and stopped audio are labelled explicitly. Stopped meters clear +instead of holding stale readings. + +Drag a dial to adjust; **Shift-drag** for fine control, **double-click / Return** +for exact entry, and **Alt/Option-click / Home** to reset. Tab and arrow keys +work too. Use your host's bypass to compare with the dry input. + +Existing sessions and automation remain compatible. The additional engine +settings remain available in the host's parameter view, not in the simplified +editor. **Host settings active** indicates non-default legacy settings; hover +for details. In multiband mode, Threshold and Strength are inactive because +that mode uses its own splits and learned thresholds. ## Building @@ -58,6 +72,17 @@ cmake -S . -B build-tests -G Ninja -DSUPPRESSOR_BUILD_PLUGIN=OFF -DSUPPRESSOR_BU cmake --build build-tests && ctest --test-dir build-tests --output-on-failure ``` +### UI and wrapper tests + +```bash +cmake --build build --target SuppressorUITests +ctest --test-dir build --output-on-failure +``` + +These also exercise native VST3/CLAP editor embedding. Linux needs a display +with a window manager (CI uses Xvfb + Openbox). Set `SUPPRESSOR_UI_CAPTURE_DIR` to an absolute directory to +save real editor captures at 100–200% scaling. + ### CI `.github/workflows/ci.yml` gates every push/PR on: diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 2602190..a0c2352 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -8,7 +8,7 @@ juce_add_binary_data(SuppressorAssets juce_add_plugin(Suppressor VERSION 0.1.0 COMPANY_NAME "Suppressor Audio" - COMPANY_WEBSITE "https://github.com/user1303836/suppressor" + COMPANY_WEBSITE "https://github.com/affinedsp/suppressor" COMPANY_EMAIL "dev@suppressor.audio" PLUGIN_MANUFACTURER_CODE SpAu PLUGIN_CODE Supr @@ -56,6 +56,7 @@ if (SUPPRESSOR_BUILD_TESTS) add_executable(SuppressorUITests) target_sources(SuppressorUITests PRIVATE + ../Tests/test_ui_main.cpp ../Tests/test_ui.cpp) target_include_directories(SuppressorUITests PRIVATE @@ -63,14 +64,33 @@ if (SUPPRESSOR_BUILD_TESTS) target_link_libraries(SuppressorUITests PRIVATE Suppressor - doctest::doctest_with_main + doctest::doctest 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) + JUCE_USE_CURL=0 + JUCE_MODAL_LOOPS_PERMITTED=1 + JUCE_PLUGINHOST_VST3=1 + $<$:_CONSOLE=1>) - add_test(NAME suppressor-ui-unit COMMAND SuppressorUITests) + # Keep assertion context in CTest's failure log, including native-window exits. + add_test(NAME suppressor-ui-unit COMMAND SuppressorUITests --success) + set_tests_properties(suppressor-ui-unit PROPERTIES ENVIRONMENT + "SUPPRESSOR_VST3_PATH=$>") + add_dependencies(SuppressorUITests Suppressor_VST3) + if (SUPPRESSOR_ENABLE_CLAP) + target_sources(SuppressorUITests PRIVATE ../Tests/test_clap_gui.cpp) + target_link_libraries(SuppressorUITests PRIVATE clap-core) + add_dependencies(SuppressorUITests Suppressor_CLAP) + if (APPLE) + set(clap_test_path "$") + else() + set(clap_test_path "$") + endif() + set_property(TEST suppressor-ui-unit APPEND PROPERTY ENVIRONMENT + "SUPPRESSOR_CLAP_PATH=${clap_test_path}") + endif() endif() diff --git a/Source/Metering.h b/Source/Metering.h new file mode 100644 index 0000000..319dc54 --- /dev/null +++ b/Source/Metering.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include + +namespace suppressor +{ +struct MeterSnapshot +{ + enum Flag : uint32_t { active = 1, bypassed = 2, removed = 4, multiband = 8, + learning = 16, inputClip = 32, outputClip = 64 }; + float input = 0.0f, output = 0.0f, reduction = 0.0f; + uint32_t flags = 0, sequence = 0; +}; + +// Single audio writer, message-thread readers. Atomic fields make even a +// rejected read data-race-free. The writer never waits for the UI. +class Metering +{ +public: + static_assert (std::atomic::is_always_lock_free); + static_assert (std::atomic::is_always_lock_free); + + void reset() noexcept + { + in = out = reduction = inClipTime = outClipTime = 0.0f; + previousRoute = 0; + publish (0); + } + + void push (float inputPeak, float outputPeak, float reductionDb, + int samples, double sampleRate, uint32_t flags) noexcept + { + if (samples <= 0 || sampleRate <= 0.0) return; + const auto route = flags & (MeterSnapshot::bypassed | MeterSnapshot::removed + | MeterSnapshot::multiband | MeterSnapshot::learning); + if (route != previousRoute) + in = out = reduction = inClipTime = outClipTime = 0.0f; + previousRoute = route; + const auto seconds = static_cast (samples / sampleRate); + const auto decay = std::exp (-seconds / 0.3f); + in = std::max (clean (inputPeak), in * decay); + out = std::max (clean (outputPeak), out * decay); + // Cap the display envelope, not just the drawing. A zero-gain startup + // sample must not leave a >100 dB peak hanging above the real depth. + reduction = (flags & (MeterSnapshot::bypassed | MeterSnapshot::learning)) != 0 + ? 0.0f : std::max (std::clamp (clean (reductionDb), 0.0f, 60.0f), + reduction * std::exp (-seconds / 0.12f)); + inClipTime = inputPeak >= 1.0f ? 1.0f : std::max (0.0f, inClipTime - seconds); + outClipTime = outputPeak >= 1.0f ? 1.0f : std::max (0.0f, outClipTime - seconds); + if (inClipTime > 0.0f) flags |= MeterSnapshot::inputClip; + if (outClipTime > 0.0f) flags |= MeterSnapshot::outputClip; + publish (flags | MeterSnapshot::active); + } + + bool read (MeterSnapshot& result) const noexcept + { + // seq_cst across the atomic fields gives a simple portable coherent + // snapshot, not a seqlock around non-atomic (racy) storage. + for (int attempt = 0; attempt < 3; ++attempt) + { + const auto before = sequence.load(); + if ((before & 1u) != 0) continue; + MeterSnapshot s { input.load(), output.load(), gr.load(), status.load(), before }; + if (before == sequence.load()) { result = s; return true; } + } + return false; + } + +private: + static float clean (float v) noexcept { return std::isfinite (v) ? std::max (0.0f, v) : 0.0f; } + void publish (uint32_t flags) noexcept + { + sequence.fetch_add (1); + input.store (in); output.store (out); gr.store (reduction); status.store (flags); + sequence.fetch_add (1); + } + + float in = 0.0f, out = 0.0f, reduction = 0.0f, inClipTime = 0.0f, outClipTime = 0.0f; + uint32_t previousRoute = 0; + std::atomic input { 0 }, output { 0 }, gr { 0 }; + std::atomic status { 0 }, sequence { 0 }; +}; +} // namespace suppressor diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index ee04536..d4b96b4 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -1,668 +1,349 @@ #include "PluginEditor.h" +#include -#include - -class ActivityDisplay::ValueInterface final : public juce::AccessibilityValueInterface +namespace { -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 {}; } +using namespace SuppressorTheme; +const auto bg = canvas, panel = surface, line = divider, text = primaryText, + secondary = secondaryText, neutral = controlOutline; -private: - ActivityDisplay& display; -}; - -void ActivityDisplay::setState (float detectorDb, float gainReductionDb, - float thresholdDb, int bandMode, bool deltaAudition, - bool multibandLearning) +void label (juce::Graphics& g, const juce::String& s, juce::Rectangle r, + float size, juce::Colour colour, juce::Justification align = juce::Justification::left) { - 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); + g.setColour (colour); + g.setFont (SuppressorTheme::makeFont (size, size >= 20.0f)); + g.drawText (s, r, align); } +float db (float peak) { return juce::Decibels::gainToDecibels (peak, -100.0f); } +juce::String levelText (float peak) { return peak < 0.00001f ? "-inf" : juce::String (db (peak), 1); } +} // namespace -juce::String ActivityDisplay::semanticStatus() const +SuppressorDial::SuppressorDial (juce::AudioProcessorValueTreeState& state, const juce::String& id, + const juce::String& help) + : parameter (*state.getParameter (id)), attachment (state, id, *this) { - 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"; + setComponentID (id); + setName (parameter.getName (64)); + setTitle (getName()); + setDescription (help); + setTooltip (help + " Double-click or Return to type. Shift-drag for fine adjustment. " + "Alt/Option-click to reset; right-click for the menu."); + setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0); + setTextValueSuffix (" " + parameter.getLabel()); + setRotaryParameters (juce::MathConstants::pi * 1.25f, + juce::MathConstants::pi * 2.75f, true); + setScrollWheelEnabled (false); + setWantsKeyboardFocus (true); + setMouseClickGrabsKeyboardFocus (true); + setDoubleClickReturnValue (false, 0.0); + addChildComponent (entry); + entry.setName (getName() + " exact value"); + entry.setJustification (juce::Justification::centred); + entry.setFont (SuppressorTheme::makeFont (18.0f)); + entry.onReturnKey = [this] { finishEntry (true); }; + entry.onEscapeKey = [this] { finishEntry (false); }; + entry.onFocusLost = [this] { finishEntry (false); }; } -juce::String ActivityDisplay::accessibleValueText() const +SuppressorDial::~SuppressorDial() { cancelInteraction(); } +void SuppressorDial::resized() { - 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; + juce::Slider::resized(); + entry.setBounds (getLocalBounds().withSizeKeepingCentre (100, 32)); } - -std::unique_ptr ActivityDisplay::createAccessibilityHandler() +void SuppressorDial::setWithGesture (double v) { - return std::make_unique ( - *this, juce::AccessibilityRole::staticText, juce::AccessibilityActions {}, - juce::AccessibilityHandler::Interfaces { std::make_unique (*this) }); + juce::Slider::ScopedDragNotification gesture (*this); + setValue (v, juce::sendNotificationSync); } - -void ActivityDisplay::paint (juce::Graphics& graphics) +void SuppressorDial::mouseDown (const juce::MouseEvent& e) { - 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) + if (! isEnabled()) return; + grabKeyboardFocus(); + if (e.mods.isPopupMenu()) { showMenu(); return; } + if (e.mods.isAltDown()) { - 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 drawMeter = [&graphics] (juce::Rectangle meterBounds, float proportion) - { - 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); - }; - - 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); - } - - 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)); + setWithGesture (parameter.convertFrom0to1 (parameter.getDefaultValue())); + return; } - - 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); + lastDrag = e.position; + dragProportion = valueToProportionOfLength (getValue()); + drag = std::make_unique (*this); } - -SuppressorEditor::SuppressorEditor (SuppressorProcessor& processorToUse) - : juce::AudioProcessorEditor (&processorToUse), proc (processorToUse) +void SuppressorDial::mouseDrag (const juce::MouseEvent& e) { - 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"); - - 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(); + if (drag == nullptr) return; + const auto delta = e.position - lastDrag; + const double sensitivity = e.mods.isShiftDown() ? 2400.0 : 240.0; + // Accumulate sub-step movement so fine drags work on the 0.1 dB grid. + dragProportion = juce::jlimit (0.0, 1.0, dragProportion + (delta.x - delta.y) / sensitivity); + setValue (proportionOfLengthToValue (dragProportion), juce::sendNotificationSync); + lastDrag = e.position; } - -SuppressorEditor::~SuppressorEditor() +void SuppressorDial::mouseUp (const juce::MouseEvent&) { drag.reset(); } +void SuppressorDial::mouseDoubleClick (const juce::MouseEvent& e) { - stopTimer(); - for (const auto& slider : sliders) - slider->endActiveGesture(); - entries.clear(); - setLookAndFeel (nullptr); + if (! e.mods.isAltDown() && ! e.mods.isPopupMenu()) beginEntry(); } - -ParameterSlider& SuppressorEditor::addKnob (const juce::String& parameterId, - const juce::String& labelText) +void SuppressorDial::cancelInteraction() { - 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 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; + drag.reset(); + finishEntry (false); } - -juce::ComboBox& SuppressorEditor::addCombo (const juce::String& parameterId, - const juce::String& labelText) +void SuppressorDial::enablementChanged() { - comboBoxes.push_back (std::make_unique()); - 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] - { - updateModePresentation(); - timerCallback(); - }; - - return *combo; + if (! isEnabled()) cancelInteraction(); + repaint(); } - -juce::ToggleButton& SuppressorEditor::addToggle (const juce::String& parameterId, - const juce::String& labelText) +bool SuppressorDial::keyPressed (const juce::KeyPress& key) { - 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; + if (! isEnabled()) return false; + if (key == juce::KeyPress::returnKey) { beginEntry(); return true; } + const int code = key.getKeyCode(); + if (code == juce::KeyPress::upKey || code == juce::KeyPress::rightKey + || code == juce::KeyPress::downKey || code == juce::KeyPress::leftKey) + { + const double step = getComponentID() == "strength" ? 0.01 : 0.1; + const int direction = code == juce::KeyPress::upKey || code == juce::KeyPress::rightKey ? 1 : -1; + const auto fine = key.getModifiers().isShiftDown() && getInterval() <= 0.0 ? 0.1 : 1.0; + setWithGesture (getValue() + direction * step * fine); + return true; + } + if (code == juce::KeyPress::homeKey) + { + setWithGesture (parameter.convertFrom0to1 (parameter.getDefaultValue())); + return true; + } + return juce::Slider::keyPressed (key); } - -void SuppressorEditor::addAction (const juce::String& parameterId, - juce::TextButton& button, - const juce::String& accessibleName) +void SuppressorDial::beginEntry() { - 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)); + if (! isEnabled()) return; + drag.reset(); + entry.setText (getTextFromValue (getValue()), false); + entry.setVisible (true); + entry.grabKeyboardFocus(); + entry.selectAll(); } - -SuppressorEditor::Entry* SuppressorEditor::findEntry (const juce::String& parameterId) const +void SuppressorDial::finishEntry (bool commit) { - for (const auto& entry : entries) - if (entry->id == parameterId) - return entry.get(); - return nullptr; + if (! entry.isVisible()) return; + const auto typed = entry.getText().trim(); + entry.setVisible (false); + if (commit) + { + // Accept one finite number plus an optional matching unit. Do not let + // the parameter's permissive parser turn malformed input into zero. + auto number = typed; + const auto unit = parameter.getLabel(); + if (number.endsWithIgnoreCase (unit)) number = number.dropLastCharacters (unit.length()).trimEnd(); + const auto utf8 = number.toRawUTF8(); + char* end = nullptr; + const auto value = std::strtod (utf8, &end); + if (number.containsOnly ("0123456789.+-") && end != utf8 && *end == '\0' && std::isfinite (value)) + { + const double plain = getComponentID() == "strength" ? value / 100.0 : value; + setWithGesture (plain); + } + grabKeyboardFocus(); + } + repaint(); } - -void SuppressorEditor::placeKnob (const juce::String& parameterId, - juce::Rectangle bounds) +void SuppressorDial::showMenu() { - if (auto* entry = findEntry (parameterId)) + juce::PopupMenu menu; + const juce::Component::SafePointer safe (this); + menu.addItem ("Enter value...", [safe] { if (safe != nullptr) safe->beginEntry(); }); + menu.addItem ("Reset to default", [safe] { - entry->label->setBounds (bounds.removeFromTop (18)); - entry->slider->setBounds (bounds); - } + if (safe != nullptr) + safe->setWithGesture (safe->parameter.convertFrom0to1 (safe->parameter.getDefaultValue())); + }); + if (auto* editor = findParentComponentOfClass()) + if (auto* host = editor->getHostContext()) + if (auto hostMenu = host->getContextMenuForParameter (¶meter)) + menu.addSubMenu ("Host", hostMenu->getEquivalentPopupMenu()); + menu.showMenuAsync (juce::PopupMenu::Options().withTargetComponent (this)); } -void SuppressorEditor::placeCombo (const juce::String& parameterId, - juce::Rectangle bounds) +SuppressorEditor::SuppressorEditor (SuppressorProcessor& p) + : juce::AudioProcessorEditor (&p), proc (p), + threshold (p.apvts, "threshold", "Highs below this level are reduced. Raise it until noise between notes is suppressed."), + strength (p.apvts, "strength", "Higher strength suppresses a wider high-frequency range. Zero is not bypass."), + release (p.apvts, "release", "How quickly suppression returns after a note. Increase to preserve longer tails."), + listenAttachment (p.apvts, "deltaAudition", listen) { - if (auto* entry = findEntry (parameterId)) + setLookAndFeel (&theme); + int order = 1; + for (auto* dial : { &threshold, &strength, &release }) + { + addAndMakeVisible (*dial); + dial->setExplicitFocusOrder (order++); + } + listen.setClickingTogglesState (true); + listen.setComponentID ("deltaAudition"); + listen.setTitle ("Listen removed"); + listen.setTooltip ("Audition input minus processed audio, including filter phase differences; not isolated noise. Turn off to hear the processed signal."); + listen.setExplicitFocusOrder (4); + addAndMakeVisible (listen); + for (auto* l : { &status, &meterSummary, &hostSettings }) { - entry->label->setBounds (bounds.getX(), bounds.getY() - 17, - bounds.getWidth(), 15); - entry->combo->setBounds (bounds); + addAndMakeVisible (*l); + l->setFont (SuppressorTheme::makeFont (16.0f)); + l->setBorderSize (juce::BorderSize (0)); } + status.setJustificationType (juce::Justification::centredRight); + status.setName ("Processing status"); + meterSummary.setName ("Signal meters"); + meterSummary.setTooltip ("Input and output: peak across channels, dBFS. Reduction: deepest gate attenuation, not overall loudness loss. 300 ms peak decay; clips held for one second."); + hostSettings.setJustificationType (juce::Justification::centredRight); + hostSettings.setName ("Host settings"); + setSize (640, 460); + // A reopened editor must see a NEW audio block before calling data live. + proc.readMeters (meter); + lastSequence = meter.sequence; + lastUpdateMs = juce::Time::getMillisecondCounterHiRes() - 1000.0; + status.setText ("No audio", juce::dontSendNotification); + timerCallback(); + startTimerHz (30); } - -void SuppressorEditor::placeButton (const juce::String& parameterId, - juce::Rectangle bounds) +SuppressorEditor::~SuppressorEditor() { - if (auto* entry = findEntry (parameterId)) - entry->button->setBounds (bounds); + stopTimer(); + setLookAndFeel (nullptr); } - 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) - { - entry->slider->setColour (juce::Slider::rotarySliderFillColourId, - active ? SuppressorTheme::accent - : SuppressorTheme::inactiveControl); - entry->slider->repaint(); - } - }; - - 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) - { - 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."); - } - }; - - 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() -{ - 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); - - 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 (multibandMode ? "READY TO LEARN" - : "CAN ARM FOR 4/6-BAND", - juce::dontSendNotification); - - updateModePresentation(); + status.setBounds (365, 24, 251, 28); + meterSummary.setBounds (36, 224, 355, 18); + threshold.setBounds (59, 277, 122, 116); + strength.setBounds (259, 277, 122, 116); + release.setBounds (459, 277, 122, 116); + listen.setBounds (24, 412, 160, 32); + hostSettings.setBounds (464, 419, 152, 20); } - void SuppressorEditor::visibilityChanged() { - updateTimerState(); + if (! isShowing()) + for (auto* dial : { &threshold, &strength, &release }) dial->cancelInteraction(); } - -void SuppressorEditor::updateTimerState() +int SuppressorEditor::getControlParameterIndex (juce::Component& c) { - startTimer (isShowing() ? 50 : 500); + for (auto* dial : { &threshold, &strength, &release }) + if (&c == dial || dial->isParentOf (&c)) return dial->parameter.getParameterIndex(); + if (&c == &listen || listen.isParentOf (&c)) + return proc.apvts.getParameter ("deltaAudition")->getParameterIndex(); + return -1; } - -int SuppressorEditor::getControlParameterIndex (juce::Component& component) +void SuppressorEditor::timerCallback() { - for (const auto& entry : entries) + const auto now = juce::Time::getMillisecondCounterHiRes(); + suppressor::MeterSnapshot next; + if (proc.readMeters (next) && next.sequence != lastSequence) { - 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)); + meter = next; + lastSequence = next.sequence; + lastUpdateMs = now; } - - return -1; + fresh = (meter.flags & suppressor::MeterSnapshot::active) != 0 && now - lastUpdateMs < 500.0; + // A parameter change may precede the next audio block (or happen while + // stopped). Do not relabel old output/reduction as the new routing mode. + if ((meter.flags & suppressor::MeterSnapshot::bypassed) == 0) + fresh = fresh + && (((meter.flags & suppressor::MeterSnapshot::removed) != 0) == listen.getToggleState()) + && (((meter.flags & suppressor::MeterSnapshot::multiband) != 0) + == (proc.apvts.getRawParameterValue ("bandMode")->load() > 0.5f)); + if (! isShowing()) return; + + using M = suppressor::MeterSnapshot; + const bool bypass = fresh && (meter.flags & M::bypassed) != 0; + const bool learning = fresh && (meter.flags & M::learning) != 0; + const bool audition = listen.getToggleState() && ! bypass; + listen.setButtonText (listen.getToggleState() ? "Stop listening" : "Listen removed"); + juce::String state = ! fresh ? "No audio" : bypass ? "Bypassed" : learning ? "Learning bands" + : meter.input < 0.00001f ? "No input" : audition ? "Listening to difference" + : meter.reduction > 0.5f ? "Suppressing" : "Passing signal"; + status.setText (state, juce::dontSendNotification); + status.setColour (juce::Label::textColourId, audition ? warning : text); + const bool multi = proc.apvts.getRawParameterValue ("bandMode")->load() > 0.5f; + threshold.setEnabled (! multi); + strength.setEnabled (! multi); + + juce::StringArray changed; + for (auto* param : proc.getParameters()) + if (auto* ranged = dynamic_cast (param)) + if (ranged->paramID != "threshold" && ranged->paramID != "strength" + && ranged->paramID != "release" && ranged->paramID != "deltaAudition" + && std::abs (ranged->getValue() - ranged->getDefaultValue()) > 0.00001f) + changed.add (ranged->getName (64) + ": " + ranged->getCurrentValueAsText()); + hostSettings.setText (changed.isEmpty() ? "" : "Host settings active", juce::dontSendNotification); + hostSettings.setTooltip ("Legacy settings are preserved. Edit them in your host's parameter view.\n" + changed.joinIntoString ("\n")); + hostSettings.setDescription (hostSettings.getTooltip()); + meterSummary.setText ("PEAK LEVEL / dBFS", juce::dontSendNotification); + meterSummary.setDescription (! fresh ? "No current audio readings" : + "Input " + levelText (meter.input) + " dBFS, " + (audition ? "removed " : "output ") + + levelText (meter.output) + " dBFS. " + (bypass ? "Bypassed." : learning ? "Learning; reduction unavailable." + : meter.input < 0.00001f ? "No input; reduction unavailable." + : (multi ? "Maximum band reduction " : "High-band reduction ") + + (meter.reduction >= 60.0f ? "at least 60" : juce::String (meter.reduction, 1)) + " dB.")); + if (fresh) + { + auto description = meterSummary.getDescription(); + if ((meter.flags & M::inputClip) != 0) description += " Input clipped."; + if ((meter.flags & M::outputClip) != 0) description += " Output clipped."; + meterSummary.setDescription (description); + } + repaint(); } - -void SuppressorEditor::paintPanel (juce::Graphics& graphics, - juce::Rectangle bounds, - const juce::String& title) const +void SuppressorEditor::drawLevel (juce::Graphics& g, int y, const juce::String& name, float peak, bool clip) { - 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)); + label (g, name, { 36, y, 66, 20 }, 16.0f, secondary); + const auto track = juce::Rectangle (106.0f, (float) y + 6.0f, 205.0f, 8.0f); + g.setColour (line); g.fillRoundedRectangle (track, 2.0f); + if (fresh) + { + g.setColour (name == "Input" ? secondary : accent); + if (name == "Removed") g.setColour (warning); + g.fillRoundedRectangle (track.withWidth (205.0f * juce::jlimit (0.0f, 1.0f, (db (peak) + 60.0f) / 60.0f)), 2.0f); + } + label (g, fresh ? levelText (peak) : "--", { 322, y - 1, 66, 22 }, 19.0f, text, juce::Justification::right); + if (fresh && clip) label (g, "CLIP", { 319, y + 19, 69, 15 }, 14.0f, error, juce::Justification::right); } - -void SuppressorEditor::paint (juce::Graphics& graphics) +void SuppressorEditor::paint (juce::Graphics& g) { - 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)); + using M = suppressor::MeterSnapshot; + const bool bypass = fresh && (meter.flags & M::bypassed) != 0; + const bool multi = proc.apvts.getRawParameterValue ("bandMode")->load() > 0.5f; + const bool learning = fresh && (meter.flags & M::learning) != 0; + const bool audition = listen.getToggleState() && ! bypass; + const bool hasReduction = fresh && ! learning && (bypass || meter.input >= 0.00001f); + g.fillAll (bg); + label (g, "suppressor", { 24, 19, 270, 32 }, 30.0f, text); + label (g, "GUITAR DI / NOISE SUPPRESSOR", { 25, 54, 340, 16 }, 14.0f, secondary); + g.setColour (panel); g.fillRoundedRectangle (24.0f, 86.0f, 592.0f, 162.0f, 8.0f); + drawLevel (g, 112, "Input", meter.input, (meter.flags & M::inputClip) != 0); + drawLevel (g, 164, audition ? "Removed" : "Output", meter.output, (meter.flags & M::outputClip) != 0); + for (int value : { -60, -36, -12, 0 }) + { + const int x = 106 + (value + 60) * 205 / 60; + g.setColour (neutral); g.drawVerticalLine (x, 195.0f, 199.0f); + label (g, juce::String (value), { x - 14, 202, 28, 16 }, 14.0f, secondary, juce::Justification::centred); + } + g.setColour (line); g.drawVerticalLine (406, 106.0f, 230.0f); + label (g, multi ? "MAX BAND REDUCTION" : "HIGH-BAND REDUCTION", { 423, 108, 178, 18 }, 14.0f, secondary); + label (g, hasReduction ? (meter.reduction >= 60.0f ? "60+" : juce::String (meter.reduction, 1)) : "--", { 422, 134, 131, 56 }, 52.0f, + hasReduction && meter.reduction > 0.5f ? accent : text); + label (g, "dB", { 548, 158, 40, 24 }, 20.0f, secondary); + const float amount = hasReduction ? juce::jlimit (0.0f, 1.0f, meter.reduction / 60.0f) : 0.0f; + g.setColour (line); g.fillRect (424.0f, 202.0f, 172.0f, 6.0f); + g.setColour (accent); g.fillRect (424.0f, 202.0f, 172.0f * amount, 6.0f); + for (int value : { 0, 20, 40, 60 }) + label (g, juce::String (value), { 416 + value * 172 / 60, 215, 22, 17 }, 14.0f, secondary, juce::Justification::centred); + + label (g, "Threshold", { 24, 259, 192, 22 }, 18.0f, text, juce::Justification::centred); + label (g, "Strength", { 224, 259, 192, 22 }, 18.0f, text, juce::Justification::centred); + label (g, "Release", { 424, 259, 192, 22 }, 18.0f, text, juce::Justification::centred); + label (g, bypass ? "Monitor: dry input" : audition ? "Monitor: input minus processed" : "Monitor: processed signal", + { 196, 419, 265, 20 }, 15.0f, audition ? warning : secondary); } diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index aa6e659..9fed4bb 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -1,91 +1,77 @@ #pragma once -#include "PluginLookAndFeel.h" #include "PluginProcessor.h" -#include +#include "PluginLookAndFeel.h" -class ActivityDisplay final : public juce::Component +class SuppressorDial final : public juce::Slider { 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; + SuppressorDial (juce::AudioProcessorValueTreeState&, const juce::String& id, + const juce::String& help); + ~SuppressorDial() override; + void resized() override; + void mouseDown (const juce::MouseEvent&) override; + void mouseDrag (const juce::MouseEvent&) override; + void mouseUp (const juce::MouseEvent&) override; + void mouseDoubleClick (const juce::MouseEvent&) override; + bool keyPressed (const juce::KeyPress&) override; + void focusGained (FocusChangeType) override { repaint(); } + void focusLost (FocusChangeType) override { repaint(); } + void enablementChanged() override; + void beginEntry(); + void cancelInteraction(); + juce::RangedAudioParameter& parameter; private: - class ValueInterface; - juce::String accessibleValueText() const; - juce::String semanticStatus() const; + void finishEntry (bool commit); + void setWithGesture (double); + void showMenu(); + juce::TextEditor entry; + juce::Point lastDrag; + double dragProportion = 0.0; + std::unique_ptr drag; + juce::AudioProcessorValueTreeState::SliderAttachment attachment; +}; - float detector = -180.0f; - float reduction = 0.0f; - float threshold = -40.0f; - int mode = 0; - bool delta = false; - bool learning = false; +class ListenButton final : public juce::TextButton +{ +public: + ListenButton() : juce::TextButton ("Listen removed") {} + bool keyPressed (const juce::KeyPress& key) override + { + if (isEnabled() && key.isKeyCode (' ')) + { + setToggleState (! getToggleState(), juce::sendNotificationSync); + return true; + } + return juce::TextButton::keyPressed (key); + } }; -class SuppressorEditor final : public juce::AudioProcessorEditor, - private juce::Timer +class SuppressorEditor final : public juce::AudioProcessorEditor, private juce::Timer { public: explicit SuppressorEditor (SuppressorProcessor&); ~SuppressorEditor() override; - void paint (juce::Graphics&) override; void resized() override; void visibilityChanged() override; int getControlParameterIndex (juce::Component&) override; private: - struct Entry - { - juce::String id; - 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 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; - + void drawLevel (juce::Graphics&, int y, const juce::String&, float peak, bool clip); SuppressorProcessor& proc; - SuppressorLookAndFeel lookAndFeel; - ActivityDisplay activityDisplay; - juce::TooltipWindow tooltipWindow { this, 1200 }; - - std::vector> sliders; - std::vector> comboBoxes; - std::vector> toggleButtons; - std::vector> entries; - - 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; + SuppressorLookAndFeel theme; + SuppressorDial threshold, strength, release; + ListenButton listen; + juce::AudioProcessorValueTreeState::ButtonAttachment listenAttachment; + juce::Label status, meterSummary, hostSettings; + juce::TooltipWindow tooltips { this, 700 }; + suppressor::MeterSnapshot meter; + uint32_t lastSequence = 0; + double lastUpdateMs = 0.0; + bool fresh = false; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SuppressorEditor) }; diff --git a/Source/PluginLookAndFeel.cpp b/Source/PluginLookAndFeel.cpp index 62409af..c529105 100644 --- a/Source/PluginLookAndFeel.cpp +++ b/Source/PluginLookAndFeel.cpp @@ -1,455 +1,85 @@ #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) +juce::Font SuppressorTheme::makeFont (float size, bool semibold) { - 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); + static auto regular = juce::Typeface::createSystemTypefaceFor ( + SuppressorAssets::BarlowCondensedRegular_ttf, SuppressorAssets::BarlowCondensedRegular_ttfSize); + static auto bold = juce::Typeface::createSystemTypefaceFor ( + SuppressorAssets::BarlowCondensedSemiBold_ttf, SuppressorAssets::BarlowCondensedSemiBold_ttfSize); + return juce::Font (juce::FontOptions (semibold ? bold : regular).withHeight (size)); } 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) + using namespace SuppressorTheme; + setColour (juce::Label::textColourId, secondaryText); + setColour (juce::TextEditor::backgroundColourId, surfaceRaised); + setColour (juce::TextEditor::textColourId, primaryText); + setColour (juce::TextEditor::outlineColourId, controlOutline); + setColour (juce::TextEditor::focusedOutlineColourId, accent); + setColour (juce::TextEditor::highlightColourId, accent.withAlpha (0.3f)); + setColour (juce::CaretComponent::caretColourId, primaryText); + setColour (juce::TextButton::textColourOffId, primaryText); + setColour (juce::TextButton::textColourOnId, warning); + setColour (juce::PopupMenu::backgroundColourId, surface); + setColour (juce::PopupMenu::textColourId, primaryText); + setColour (juce::PopupMenu::highlightedBackgroundColourId, surfaceRaised); + setColour (juce::PopupMenu::highlightedTextColourId, accent); + setColour (juce::TooltipWindow::backgroundColourId, surfaceRaised); + setColour (juce::TooltipWindow::textColourId, primaryText); + setColour (juce::TooltipWindow::outlineColourId, controlOutline); +} + +void SuppressorLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int w, int h, + float position, float start, float end, juce::Slider& s) +{ + using namespace SuppressorTheme; + const auto bounds = juce::Rectangle (x, y, w, h).toFloat().reduced (8.0f); + const auto centre = bounds.getCentre(); + const auto radius = juce::jmin (bounds.getWidth(), bounds.getHeight()) * 0.5f - 4.0f; + g.setColour (surfaceRaised); + g.fillEllipse (bounds.reduced (7.0f)); + juce::Path track, value; + track.addCentredArc (centre.x, centre.y, radius, radius, 0.0f, start, end, true); + value.addCentredArc (centre.x, centre.y, radius, radius, 0.0f, start, + start + position * (end - start), true); + g.setColour (controlOutline); + g.strokePath (track, juce::PathStrokeType (3.0f)); + g.setColour (s.isEnabled() ? accent : controlOutline); + g.strokePath (value, juce::PathStrokeType (3.0f)); + const auto angle = start + position * (end - start); + const auto point = centre + juce::Point (std::sin (angle), -std::cos (angle)) * radius; + g.fillEllipse (point.x - 3.0f, point.y - 3.0f, 6.0f, 6.0f); + g.setColour (s.isEnabled() ? primaryText : secondaryText); + g.setFont (makeFont (21.0f, true)); + g.drawText (s.getTextFromValue (s.getValue()), bounds, juce::Justification::centred); + if (s.hasKeyboardFocus (true)) { - 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)); + g.setColour (accent); + g.drawRoundedRectangle (s.getLocalBounds().toFloat().reduced (1.0f), 6.0f, 1.5f); } - - 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) + else if (s.isMouseOver() && s.isEnabled()) { - graphics.setColour (SuppressorTheme::canvas.withMultipliedAlpha (alpha)); - graphics.drawRoundedRectangle (bounds.reduced (3.0f), 4.0f, 1.5f); + g.setColour (controlOutline); + g.drawRoundedRectangle (s.getLocalBounds().toFloat().reduced (1.0f), 6.0f, 1.0f); } } -void SuppressorLookAndFeel::drawButtonText (juce::Graphics& graphics, - juce::TextButton& button, - bool, bool) +void SuppressorLookAndFeel::drawButtonBackground (juce::Graphics& g, juce::Button& b, + const juce::Colour&, bool hover, bool down) { - 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); + using namespace SuppressorTheme; + const auto r = b.getLocalBounds().toFloat().reduced (1.0f); + g.setColour (down ? divider : surfaceRaised); + g.fillRoundedRectangle (r, 5.0f); + g.setColour (b.getToggleState() ? warning : (hover ? primaryText : controlOutline)); + g.drawRoundedRectangle (r, 5.0f, b.hasKeyboardFocus (true) ? 2.0f : 1.0f); + if (b.hasKeyboardFocus (true)) g.drawRoundedRectangle (r.reduced (4.0f), 3.0f, 1.0f); } -juce::Font SuppressorLookAndFeel::getTextButtonFont (juce::TextButton&, int buttonHeight) +juce::Font SuppressorLookAndFeel::getTextButtonFont (juce::TextButton&, int) { - return SuppressorTheme::makeFont (juce::jmin (15.0f, static_cast (buttonHeight) * 0.5f), - true, 0.04f); + return SuppressorTheme::makeFont (17.0f, true); } +juce::Font SuppressorLookAndFeel::getPopupMenuFont() { return SuppressorTheme::makeFont (17.0f); } diff --git a/Source/PluginLookAndFeel.h b/Source/PluginLookAndFeel.h index a9088b3..a5448be 100644 --- a/Source/PluginLookAndFeel.h +++ b/Source/PluginLookAndFeel.h @@ -2,82 +2,23 @@ #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); +inline const juce::Colour canvas { 0xff0b0e11 }, surface { 0xff12171c }, + surfaceRaised { 0xff171d23 }, divider { 0xff2a333c }, primaryText { 0xffe8edf1 }, + secondaryText { 0xff89939d }, controlOutline { 0xff66717b }, accent { 0xffb8f24a }, + warning { 0xffe7a84b }, error { 0xffff8080 }; +juce::Font makeFont (float size, bool semibold = false); } -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, + void drawRotarySlider (juce::Graphics&, int, int, int, int, float, float, float, 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; + bool, bool) override; + juce::Font getTextButtonFont (juce::TextButton&, int) override; + juce::Font getPopupMenuFont() override; }; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index b3698ae..3519036 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -126,9 +126,10 @@ void SuppressorProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) setLatencySamples (currentLatency); engine.setTargets (readTargets (sampleRate)); engine.reset(); + meters.reset(); } -void SuppressorProcessor::releaseResources() {} +void SuppressorProcessor::releaseResources() { meters.reset(); } bool SuppressorProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const { @@ -244,15 +245,24 @@ void SuppressorProcessor::processBlock (juce::AudioBuffer& buffer, juce:: if (numSc > 1) sc[1] = scBus.getReadPointer (1); } + const float inputPeak = mainBus.getMagnitude (0, mainBus.getNumSamples()); engine.processBlock (io, sc, numSc, buffer.getNumSamples(), numMain); - detectorDbAtomic.store (engine.detectorLevelDb()); - grDbAtomic.store (engine.gainReductionDb()); + using M = suppressor::MeterSnapshot; + uint32_t flags = targets.deltaAudition ? M::removed : 0u; + if (targets.bandMode != 0) flags |= M::multiband; + if (targets.bandMode != 0 && engine.multi (0).learning()) flags |= M::learning; + meters.push (inputPeak, mainBus.getMagnitude (0, mainBus.getNumSamples()), + -engine.gainReductionDb(), buffer.getNumSamples(), fs, flags); } -void SuppressorProcessor::processBlockBypassed (juce::AudioBuffer&, juce::MidiBuffer& midi) +void SuppressorProcessor::processBlockBypassed (juce::AudioBuffer& buffer, juce::MidiBuffer& midi) { midi.clear(); + auto mainBus = getBusBuffer (buffer, true, 0); + const float peak = mainBus.getMagnitude (0, mainBus.getNumSamples()); + meters.push (peak, peak, 0.0f, buffer.getNumSamples(), getSampleRate(), + suppressor::MeterSnapshot::bypassed); } void SuppressorProcessor::handleAsyncUpdate() @@ -264,30 +274,6 @@ void SuppressorProcessor::handleAsyncUpdate() prm->setValueNotifyingHost (0.0f); } -// ------------------------------------------------------------------------ -bool SuppressorProcessor::humLearning() const noexcept { return humLearningNow; } -bool SuppressorProcessor::humLocked() const noexcept -{ - if (engine.numChannels() == 0) return false; - return const_cast (engine).hum (0).locked(); -} -bool SuppressorProcessor::bandsLearning() const noexcept { return bandsLearningNow; } -bool SuppressorProcessor::bandsLocked() const noexcept -{ - if (engine.numChannels() == 0) return false; - return const_cast (engine).multi (0).locked(); -} -int SuppressorProcessor::humActiveDips() const noexcept -{ - if (engine.numChannels() == 0) return 0; - return const_cast (engine).hum (0).activeHarmonicCount(); -} -double SuppressorProcessor::humDetectedBaseHz() const noexcept -{ - if (engine.numChannels() == 0) return 60.0; - return const_cast (engine).hum (0).baseHz(); -} - // ------------------------------------------------------------------------ juce::AudioProcessorEditor* SuppressorProcessor::createEditor() { diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h index 9571752..242e1d4 100644 --- a/Source/PluginProcessor.h +++ b/Source/PluginProcessor.h @@ -2,6 +2,7 @@ #include #include "DSP/DenoiserEngine.h" +#include "Metering.h" class SuppressorProcessor : public juce::AudioProcessor, private juce::AsyncUpdater @@ -38,17 +39,8 @@ class SuppressorProcessor : public juce::AudioProcessor, // ------------------------------------------------------------------ juce::AudioProcessorValueTreeState apvts; - // Metering / status for the editor (read on the message thread) - float detectorDb() const noexcept { return detectorDbAtomic.load(); } - float gainReductionDb() const noexcept { return grDbAtomic.load(); } - bool humLearning() const noexcept; - bool humLocked() const noexcept; - bool bandsLearning() const noexcept; - bool bandsLocked() const noexcept; - int humActiveDips() const noexcept; - double humDetectedBaseHz() const noexcept; - void requestHumLearnStop() { humLearnRequest = false; } - void requestBandsLearnStop() { bandsLearnRequest = false; } + // Bounded, race-free telemetry; never expose mutable DSP state to the editor. + bool readMeters (suppressor::MeterSnapshot& result) const noexcept { return meters.read (result); } static juce::AudioProcessorValueTreeState::ParameterLayout createParameterLayout(); @@ -58,13 +50,12 @@ class SuppressorProcessor : public juce::AudioProcessor, suppressor::DenoiserEngine engine; - std::atomic detectorDbAtomic { -120.0f }; - std::atomic grDbAtomic { 0.0f }; + suppressor::Metering meters; std::atomic latencyDirty { false }; std::atomic humAutoFinished { false }; - bool humLearnRequest = false, humLearningNow = false; - bool bandsLearnRequest = false, bandsLearningNow = false; + bool humLearningNow = false; + bool bandsLearningNow = false; int currentLatency = 0; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SuppressorProcessor) diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index bf6ebe9..c6af2d2 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -12,9 +12,11 @@ add_executable(suppressor-tests test_engine.cpp test_hum.cpp test_multiband.cpp - test_cue.cpp) + test_cue.cpp + test_metering.cpp) -target_link_libraries(suppressor-tests PRIVATE suppressor-dsp doctest::doctest) +find_package(Threads REQUIRED) +target_link_libraries(suppressor-tests PRIVATE suppressor-dsp doctest::doctest Threads::Threads) target_include_directories(suppressor-tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_test(NAME suppressor-dsp-unit COMMAND suppressor-tests) diff --git a/Tests/test_clap_gui.cpp b/Tests/test_clap_gui.cpp new file mode 100644 index 0000000..5dddd1a --- /dev/null +++ b/Tests/test_clap_gui.cpp @@ -0,0 +1,100 @@ +#include +#include +#include + +namespace +{ +struct ClapHost +{ + juce::DynamicLibrary library; + const clap_plugin_entry_t* entry = nullptr; + const clap_plugin_t* plugin = nullptr; + const clap_plugin_gui_t* gui = nullptr; + bool initialised = false, created = false; + std::atomic callbackRequested { false }; + clap_host_t host { CLAP_VERSION, this, "Suppressor UI tests", "Suppressor Audio", + "https://github.com/affinedsp/suppressor", "1.0", + [] (const clap_host_t*, const char*) -> const void* { return nullptr; }, + [] (const clap_host_t*) {}, [] (const clap_host_t*) {}, + [] (const clap_host_t* h) { static_cast (h->host_data)->callbackRequested = true; } }; + void pump() + { + if (callbackRequested.exchange (false) && plugin != nullptr) + { + plugin->on_main_thread (plugin); + } + juce::MessageManager::getInstance()->runDispatchLoopUntil (40); + } + ~ClapHost() + { + if (created) gui->destroy (plugin); + if (plugin != nullptr) plugin->destroy (plugin); + if (initialised) entry->deinit(); + } +}; +} + +TEST_CASE ("CLAP embeds, hides, reopens and destroys the actual native editor") +{ + const auto path = juce::SystemStats::getEnvironmentVariable ("SUPPRESSOR_CLAP_PATH", {}); + if (path.isEmpty()) return; + juce::ScopedJuceInitialiser_GUI initialiser; + juce::Component parent; + parent.setBounds (80, 80, 640, 460); + parent.addToDesktop (0); + parent.setVisible (true); + ClapHost h; + auto binary = juce::File (path); + #if JUCE_MAC + binary = binary.getChildFile ("Contents/MacOS/suppressor"); + #endif + REQUIRE (h.library.open (binary.getFullPathName())); + h.entry = static_cast (h.library.getFunction ("clap_entry")); + REQUIRE (h.entry != nullptr); + h.initialised = h.entry->init (path.toRawUTF8()); + REQUIRE (h.initialised); + auto* factory = static_cast (h.entry->get_factory (CLAP_PLUGIN_FACTORY_ID)); + REQUIRE (factory != nullptr); + REQUIRE (factory->get_plugin_count (factory) == 1); + h.plugin = factory->create_plugin (factory, &h.host, factory->get_plugin_descriptor (factory, 0)->id); + REQUIRE (h.plugin != nullptr); + REQUIRE (h.plugin->init (h.plugin)); + h.gui = static_cast (h.plugin->get_extension (h.plugin, CLAP_EXT_GUI)); + REQUIRE (h.gui != nullptr); + clap_window_t window {}; + #if JUCE_MAC + window.api = CLAP_WINDOW_API_COCOA; + window.cocoa = parent.getPeer()->getNativeHandle(); + #elif JUCE_WINDOWS + window.api = CLAP_WINDOW_API_WIN32; + window.win32 = parent.getPeer()->getNativeHandle(); + #else + window.api = CLAP_WINDOW_API_X11; + window.x11 = reinterpret_cast (parent.getPeer()->getNativeHandle()); + #endif + REQUIRE (h.gui->is_api_supported (h.plugin, window.api, false)); + for (int reopen = 0; reopen < 3; ++reopen) + { + h.created = h.gui->create (h.plugin, window.api, false); + REQUIRE (h.created); + uint32_t width = 0, height = 0; + REQUIRE (h.gui->get_size (h.plugin, &width, &height)); + CHECK (width == 640); + CHECK (height == 460); + CHECK_FALSE (h.gui->can_resize (h.plugin)); + REQUIRE (h.gui->set_parent (h.plugin, &window)); + REQUIRE (h.gui->show (h.plugin)); + h.pump(); + // The pinned wrapper returns false from hide(). Embedded hosts can + // hide their containing window instead; exercise that fallback too. + h.gui->hide (h.plugin); + parent.setVisible (false); + h.pump(); + parent.setVisible (true); + REQUIRE (h.gui->show (h.plugin)); + h.pump(); + h.gui->destroy (h.plugin); + h.created = false; + h.pump(); + } +} diff --git a/Tests/test_metering.cpp b/Tests/test_metering.cpp new file mode 100644 index 0000000..5a25dbc --- /dev/null +++ b/Tests/test_metering.cpp @@ -0,0 +1,89 @@ +#include +#include "Metering.h" +#include +#include + +using suppressor::Metering; +using M = suppressor::MeterSnapshot; + +TEST_CASE ("meter snapshots start empty and reset on release") +{ + Metering meter; + M s; + REQUIRE (meter.read (s)); + CHECK (s.flags == 0); + meter.push (0.5f, 0.25f, 20.0f, 480, 48000, 0); + REQUIRE (meter.read (s)); + CHECK (s.input == 0.5f); + CHECK (s.output == 0.25f); + CHECK (s.reduction == 20.0f); + CHECK ((s.flags & M::active) != 0); + const auto sequence = s.sequence; + meter.push (1, 1, 1, 0, 48000, 0); + meter.push (1, 1, 1, 480, 0, 0); + REQUIRE (meter.read (s)); + CHECK (s.sequence == sequence); + meter.reset(); + REQUIRE (meter.read (s)); + CHECK (s.flags == 0); + CHECK (s.input == 0); + CHECK (s.reduction == 0); +} + +TEST_CASE ("peak decay is time-based and clips are held for one second") +{ + for (int block : { 48, 480, 4800 }) + { + Metering meter; + M s; + meter.push (1.1f, 0.4f, 40.0f, block, 48000, 0); + for (int n = 0; n < 14400 / block; ++n) meter.push (0, 0, 0, block, 48000, 0); + REQUIRE (meter.read (s)); + CHECK (s.input == doctest::Approx (1.1f * std::exp (-1.0f)).epsilon (0.0001)); + CHECK (s.reduction == doctest::Approx (40.0f * std::exp (-2.5f)).epsilon (0.0001)); + CHECK ((s.flags & M::inputClip) != 0); + CHECK ((s.flags & M::outputClip) == 0); + for (int n = 0; n < 48000 / block; ++n) meter.push (0, 0, 0, block, 48000, 0); + REQUIRE (meter.read (s)); + CHECK ((s.flags & M::inputClip) == 0); + } +} + +TEST_CASE ("bypass and learning never claim suppression, nonfinite input cannot poison meters") +{ + Metering meter; + M s; + meter.push (0.5f, 0.1f, 40, 480, 48000, 0); + for (auto flags : { M::bypassed, M::learning }) + { + meter.push (0.5f, 0.5f, 40, 480, 48000, flags); + REQUIRE (meter.read (s)); + CHECK (s.reduction == 0); + } + meter.push (std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), -2, 480, 48000, 0); + REQUIRE (meter.read (s)); + CHECK (std::isfinite (s.input)); + CHECK (std::isfinite (s.output)); + CHECK (s.reduction >= 0); +} + +TEST_CASE ("concurrent meter reads are coherent and never block the writer") +{ + Metering meter; + std::atomic done { false }; + std::thread writer ([&] + { + for (int n = 1; n <= 100000; ++n) + meter.push (static_cast (n), static_cast (n * 2), 0, 48, 48000, 0); + done.store (true); + }); + bool coherent = true; + do + { + M s; + if (meter.read (s)) coherent = coherent && s.output == s.input * 2 && (s.sequence & 1u) == 0; + } while (! done.load()); + writer.join(); + CHECK (coherent); +} diff --git a/Tests/test_ui.cpp b/Tests/test_ui.cpp index 666a473..dbb5458 100644 --- a/Tests/test_ui.cpp +++ b/Tests/test_ui.cpp @@ -1,429 +1,445 @@ #include - #include "PluginEditor.h" -#include "PluginLookAndFeel.h" - -#include #include -#include #include namespace { -float relativeLuminance (juce::Colour colour) +void pump (int ms = 50) +{ + juce::MessageManager::getInstance()->runDispatchLoopUntil (ms); + juce::Timer::callPendingTimersSynchronously(); +} +template +bool waitFor (Predicate predicate) { - auto linearise = [] (float value) + // Message/timer delivery is asynchronous, especially on loaded CI hosts. + // Wait for the actual state with a deadline, not an assumed 50 ms tick. + const auto deadline = juce::Time::getMillisecondCounterHiRes() + 2000.0; + do { - 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()); + pump (20); + if (predicate()) return true; + } while (juce::Time::getMillisecondCounterHiRes() < deadline); + return false; } - -float contrastRatio (juce::Colour first, juce::Colour second) +void expectStatus (juce::Label& status, const juce::String& expected) { - 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); + const bool matched = waitFor ([&] { return status.getText() == expected; }); + CHECK_MESSAGE (matched, "Expected ", expected, "; got ", status.getText()); } - -int countPixelsNear (juce::Image& image, juce::Colour target) +void setParameter (SuppressorProcessor& p, const char* id, float plain) { - 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; + auto* parameter = p.apvts.getParameter (id); + parameter->setValueNotifyingHost (parameter->convertTo0to1 (plain)); } - -juce::MouseEvent makeMouseEvent (juce::Component& target, juce::ModifierKeys modifiers, - int clicks) +void prepare (juce::AudioProcessor& p) { - 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 }; + p.setRateAndBufferSizeDetails (48000, 256); + p.prepareToPlay (48000, 256); } - -struct ParameterGestureCounter final : juce::AudioProcessorParameter::Listener +void feed (juce::AudioProcessor& p, int blocks = 120, bool silence = false, bool bypass = false) { - void parameterValueChanged (int, float) override {} - void parameterGestureChanged (int, bool starting) override + juce::AudioBuffer buffer (2, 256); + juce::MidiBuffer midi; + for (int b = 0; b < blocks; ++b) { - starting ? ++starts : ++ends; - } - - void reset() - { - starts = 0; - ends = 0; + for (int n = 0; n < 256; ++n) + { + const auto phase = juce::MathConstants::twoPi * (b * 256 + n) / 48000.0; + const auto sample = silence ? 0.0f : static_cast (0.08 * std::sin (220.0 * phase) + + 0.003 * std::sin (11000.0 * phase)); + buffer.setSample (0, n, sample); + buffer.setSample (1, n, sample * 0.7f); + } + if (bypass) p.processBlockBypassed (buffer, midi); else p.processBlock (buffer, midi); } - - int starts = 0; - int ends = 0; -}; - } - -TEST_CASE ("theme colours meet text and essential-control contrast targets") +juce::MouseEvent event (juce::Component& c, juce::Point pos = { 60, 60 }, int modifiers = juce::ModifierKeys::leftButtonModifier) { - 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); + const auto now = juce::Time::getCurrentTime(); + return { juce::Desktop::getInstance().getMainMouseSource(), pos, juce::ModifierKeys (modifiers), + 1, 0, 0, 0, 0, &c, &c, now, { 60, 60 }, now, 1, true }; } - -TEST_CASE ("rotary value is represented by a proportional accent arc") +struct Gestures final : juce::AudioProcessorParameter::Listener { - 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") + void parameterValueChanged (int, float) override { ++changes; } + void parameterGestureChanged (int, bool start) override { start ? ++starts : ++ends; } + int starts = 0, ends = 0, changes = 0; +}; +juce::Label* namedLabel (juce::Component& editor, const juce::String& name) { - 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); + for (auto* c : editor.getChildren()) + if (c->getName() == name) return dynamic_cast (c); + return nullptr; } - -TEST_CASE ("bundled display font is available in both weights") +void capture (juce::Component& editor, const juce::String& name, float scale = 1.0f) { - 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()); + const auto path = juce::SystemStats::getEnvironmentVariable ("SUPPRESSOR_UI_CAPTURE_DIR", {}); + if (path.isEmpty()) return; + auto dir = juce::File (path); + REQUIRE (dir.createDirectory().wasOk()); + const auto image = editor.createComponentSnapshot (editor.getLocalBounds(), true, scale); + auto file = dir.getChildFile (name + ".png"); + file.deleteFile(); + auto stream = file.createOutputStream(); + REQUIRE (stream != nullptr); + REQUIRE (juce::PNGImageFormat().writeImageToStream (image, *stream)); + stream->flush(); } - -TEST_CASE ("double-click opens exact value entry instead of resetting") +float contrast (juce::Colour a, juce::Colour b) { - 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); + auto lum = [] (juce::Colour c) + { + auto linear = [] (float v) { return v <= 0.04045f ? v / 12.92f : std::pow ((v + 0.055f) / 1.055f, 2.4f); }; + return .2126f * linear (c.getFloatRed()) + .7152f * linear (c.getFloatGreen()) + .0722f * linear (c.getFloatBlue()); + }; + return (std::max (lum (a), lum (b)) + .05f) / (std::min (lum (a), lum (b)) + .05f); } +} // namespace -TEST_CASE ("invalid exact-entry text preserves the current value") +TEST_CASE ("theme retains bundled fonts and accessible contrast") { - 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)); + using namespace SuppressorTheme; + CHECK (makeFont (18).getTypefaceName() == "Barlow Condensed"); + CHECK (makeFont (18).getTypefaceStyle() != makeFont (18, true).getTypefaceStyle()); + for (auto colour : { primaryText, secondaryText, accent, warning, error }) + CHECK (contrast (colour, surface) >= 4.5f); + CHECK (contrast (controlOutline, surfaceRaised) >= 3.0f); } -TEST_CASE ("Alt/Option-click remains the non-conflicting default reset gesture") +TEST_CASE ("four controls preserve the complete host parameter and saved-state contract") { - 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) + SuppressorProcessor p; + SuppressorEditor editor (p); + editor.addToDesktop (0); + editor.setVisible (true); + const juce::StringArray ids { "strength", "threshold", "release", "gateMode", "depth", "hysteresis", "hold", + "adaptiveRelease", "cue", "sidechain", "lookahead", "humEnable", "humBase", "humHarmonics", "humStrength", + "humLearn", "bandMode", "bandsLearn", "deltaAudition", "outputGain" }; + const std::array defaults { .9f, -40, 6, 0, 40, 6, 2, 0, 0, 0, 0, 0, 0, 8, 1, 0, 0, 0, 0, 0 }; + REQUIRE (p.getParameters().size() == ids.size()); + CHECK (editor.getWidth() == 640); + CHECK (editor.getHeight() == 460); + CHECK_FALSE (editor.isResizable()); + std::set focus; + int controls = 0; + for (int i = 0; i < ids.size(); ++i) { - 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)) + auto* param = p.apvts.getParameter (ids[i]); + REQUIRE (param != nullptr); + CHECK (p.getParameters()[i] == param); + CHECK (param->getVersionHint() == 1); + CHECK (param->convertFrom0to1 (param->getDefaultValue()) == doctest::Approx (defaults[static_cast (i)])); + auto* component = editor.findChildWithID (ids[i]); + const bool visible = ids[i] == "strength" || ids[i] == "threshold" || ids[i] == "release" || ids[i] == "deltaAudition"; + CHECK ((component != nullptr) == visible); + if (component != nullptr) { - CHECK (slider->getTextFromValue (slider->getValue()).isNotEmpty()); - REQUIRE (slider->getNumChildComponents() > 0); - CHECK (editor->getControlParameterIndex (*slider->getChildComponent (0)) - == parameterIndex); + ++controls; + CHECK (editor.getControlParameterIndex (*component) == i); + CHECK (component->getWidth() >= 32); + CHECK (component->getHeight() >= 32); + CHECK (component->getWantsKeyboardFocus()); + CHECK (component->getName().isNotEmpty()); + CHECK (component->getAccessibilityHandler() != nullptr); + CHECK (focus.insert (component->getExplicitFocusOrder()).second); } + param->setValueNotifyingHost (param->convertTo0to1 (param->convertFrom0to1 (0.27f))); } - 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); + CHECK (controls == 4); + juce::MemoryBlock saved; + p.getStateInformation (saved); + SuppressorProcessor recalled; + recalled.setStateInformation (saved.getData(), static_cast (saved.getSize())); + for (const auto& id : ids) + CHECK (recalled.apvts.getParameter (id)->getValue() == doctest::Approx (p.apvts.getParameter (id)->getValue())); +} - 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)); +TEST_CASE ("dials support balanced drag, fine, exact, cancel, invalid, reset and close gestures") +{ + juce::ScopedJuceInitialiser_GUI gui; + SuppressorProcessor p; + auto editor = std::make_unique (p); + editor->addToDesktop (0); + editor->setVisible (true); + auto* dial = dynamic_cast (editor->findChildWithID ("strength")); + REQUIRE (dial != nullptr); + auto& param = dial->parameter; + Gestures gestures; + param.addListener (&gestures); + CHECK (dial->getComponentAt (dial->getLocalBounds().getCentre()) == dial); + setParameter (p, "strength", .5f); + dial->mouseDown (event (*dial)); + dial->mouseDrag (event (*dial, { 60, 36 })); + dial->mouseUp (event (*dial)); + CHECK (dial->getValue() == doctest::Approx (.6)); 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()); + dial->mouseDown (event (*dial)); + dial->mouseDrag (event (*dial, { 60, 36 }, juce::ModifierKeys::leftButtonModifier | juce::ModifierKeys::shiftModifier)); + dial->mouseUp (event (*dial)); + CHECK (dial->getValue() == doctest::Approx (.61)); + CHECK (dial->keyPressed (juce::KeyPress (juce::KeyPress::rightKey))); + CHECK (dial->getValue() == doctest::Approx (.62)); + auto* threshold = dynamic_cast (editor->findChildWithID ("threshold")); + REQUIRE (threshold != nullptr); + threshold->mouseDown (event (*threshold)); + for (int pixel = 1; pixel <= 6; ++pixel) + threshold->mouseDrag (event (*threshold, { 60, 60.0f - static_cast (pixel) }, + juce::ModifierKeys::leftButtonModifier | juce::ModifierKeys::shiftModifier)); + threshold->mouseUp (event (*threshold)); + CHECK (threshold->getValue() == doctest::Approx (-39.8)); + + auto enter = [&] (const juce::String& value, bool commit) + { + dial->keyPressed (juce::KeyPress (juce::KeyPress::returnKey)); + auto* entry = dynamic_cast (dial->getChildComponent (0)); + REQUIRE (entry != nullptr); + REQUIRE (entry->isVisible()); + CHECK (editor->getControlParameterIndex (*entry) == param.getParameterIndex()); + entry->setText (value); + if (commit) entry->onReturnKey(); else entry->onEscapeKey(); + CHECK_FALSE (entry->isVisible()); + }; + enter ("35 %", true); + CHECK (param.convertFrom0to1 (param.getValue()) == doctest::Approx (.35)); + enter ("80 %", false); + CHECK (dial->getValue() == doctest::Approx (.35)); + for (const auto& invalid : { "junk", "nan", "inf", "--12", "12 garbage", "0x10", "1e2", "" }) + { + enter (invalid, true); + CHECK (dial->getValue() == doctest::Approx (.35)); + } + enter ("999 %", true); + CHECK (dial->getValue() == 1.0); + dial->mouseDown (event (*dial, { 60, 60 }, juce::ModifierKeys::leftButtonModifier | juce::ModifierKeys::altModifier)); + CHECK (dial->getValue() == doctest::Approx (.9)); + setParameter (p, "strength", .42f); + CHECK (dial->getValue() == doctest::Approx (.42)); + capture (*editor, "suppressor-exact-entry-before"); + dial->beginEntry(); + capture (*editor, "suppressor-exact-entry"); + CHECK (gestures.starts == gestures.ends); + auto* listen = dynamic_cast (editor->findChildWithID ("deltaAudition")); + REQUIRE (listen != nullptr); + Gestures buttonGestures; + auto* listenParam = p.apvts.getParameter ("deltaAudition"); + listenParam->addListener (&buttonGestures); + CHECK (listen->keyPressed (juce::KeyPress (' '))); + CHECK (listenParam->getValue() == doctest::Approx (1.0)); 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); + listenParam->removeListener (&buttonGestures); + dial->mouseDown (event (*dial)); + editor->setVisible (false); + CHECK (gestures.starts == gestures.ends); + editor->setVisible (true); + dial->mouseDown (event (*dial)); + CHECK (gestures.starts == gestures.ends + 1); + editor.reset(); + CHECK (gestures.starts == gestures.ends); + param.removeListener (&gestures); +} - juce::Component* activity = nullptr; - juce::Label* bandsStatus = nullptr; - for (auto* child : editor->getChildren()) +TEST_CASE ("processor metering follows actual output without altering DSP") +{ + juce::ScopedJuceInitialiser_GUI gui; + SuppressorProcessor p; + prepare (p); + suppressor::DenoiserEngine reference; + reference.prepare (48000, 2, 120); + suppressor::EngineParams targets; + targets.strength01 = p.apvts.getRawParameterValue ("strength")->load(); + reference.setTargets (targets); + reference.reset(); + juce::AudioBuffer audio (2, 256), expected (2, 256); + juce::MidiBuffer midi; + float* io[] { expected.getWritePointer (0), expected.getWritePointer (1) }; + for (int block = 0; block < 50; ++block) { - if (child->getName() == "Suppression activity") - activity = child; - if (child->getName() == "Multiband learning status") - bandsStatus = dynamic_cast (child); + for (int ch = 0; ch < 2; ++ch) + for (int n = 0; n < 256; ++n) + audio.setSample (ch, n, static_cast (.03 * std::sin ((block * 256 + n) * .12))); + expected.makeCopyOf (audio); + reference.setTargets (targets); + reference.processBlock (io, nullptr, 0, 256, 2); + p.processBlock (audio, midi); + for (int ch = 0; ch < 2; ++ch) + CHECK (std::equal (audio.getReadPointer (ch), audio.getReadPointer (ch) + 256, expected.getReadPointer (ch))); } - 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")); + for (bool delta : { false, true }) + { + p.releaseResources(); + prepare (p); + setParameter (p, "deltaAudition", delta ? 1.0f : 0.0f); + setParameter (p, "outputGain", 6); + audio.clear(); + audio.setSample (1, 0, .8f); + p.processBlock (audio, midi); + suppressor::MeterSnapshot s; + REQUIRE (p.readMeters (s)); + CHECK (s.input == doctest::Approx (.8f)); + CHECK (s.output == doctest::Approx (audio.getMagnitude (0, 256))); + CHECK (((s.flags & suppressor::MeterSnapshot::removed) != 0) == delta); + } + p.releaseResources(); + suppressor::MeterSnapshot s; + REQUIRE (p.readMeters (s)); + CHECK (s.flags == 0); +} - gestures.reset(); +TEST_CASE ("live meter states, stale data, legacy modes and monitor labels are explicit") +{ + juce::ScopedJuceInitialiser_GUI gui; + SuppressorProcessor p; + prepare (p); + auto editor = std::make_unique (p); + editor->addToDesktop (0); + editor->setVisible (true); + pump(); + juce::Component::unfocusAllComponents(); + auto* status = namedLabel (*editor, "Processing status"); + auto* summary = namedLabel (*editor, "Signal meters"); + REQUIRE (status != nullptr); + REQUIRE (summary != nullptr); + CHECK (status->getText() == "No audio"); + capture (*editor, "suppressor-default"); + feed (p); + expectStatus (*status, "Suppressing"); + CHECK (summary->getDescription().contains ("High-band reduction")); + capture (*editor, "suppressor-ui"); + for (float scale : { 1.0f, 1.25f, 1.5f, 1.75f, 2.0f }) { - auto dragEvent = makeMouseEvent ( - *strength, juce::ModifierKeys (juce::ModifierKeys::leftButtonModifier), 1); - strength->mouseDown (dragEvent); + editor->setScaleFactor (scale); + CHECK (editor->getWidth() == 640); + CHECK (editor->getLocalBounds().toFloat().transformedBy (editor->getTransform()).getWidth() + == doctest::Approx (640 * scale)); + capture (*editor, "suppressor-scale-" + juce::String (juce::roundToInt (scale * 100)), scale); } - CHECK (gestures.starts == 1); - CHECK (gestures.ends == 0); + editor->setScaleFactor (1); + setParameter (p, "deltaAudition", 1); + expectStatus (*status, "No audio"); // old output is not relabelled as removed audio + feed (p); + expectStatus (*status, "Listening to difference"); + CHECK (summary->getDescription().contains ("removed")); + capture (*editor, "suppressor-delta-audition"); + feed (p, 120, false, true); + expectStatus (*status, "Bypassed"); + capture (*editor, "suppressor-bypassed"); + setParameter (p, "deltaAudition", 0); + feed (p, 700, true); + expectStatus (*status, "No input"); + capture (*editor, "suppressor-silence"); + setParameter (p, "bandMode", 1); setParameter (p, "bandsLearn", 1); + feed (p); + expectStatus (*status, "Learning bands"); + CHECK (summary->getDescription().contains ("unavailable")); + CHECK_FALSE (editor->findChildWithID ("threshold")->isEnabled()); + CHECK_FALSE (editor->findChildWithID ("strength")->isEnabled()); + CHECK (namedLabel (*editor, "Host settings")->getText() == "Host settings active"); + capture (*editor, "suppressor-learning"); + setParameter (p, "bandsLearn", 0); feed (p); + CHECK (waitFor ([&] { return summary->getDescription().contains ("Maximum band reduction"); })); + expectStatus (*status, "No audio"); + CHECK (summary->getDescription() == "No current audio readings"); editor.reset(); - CHECK (gestures.ends == 1); - strengthParameter->removeListener (&gestures); + editor = std::make_unique (p); + editor->addToDesktop (0); + editor->setVisible (true); pump(); + CHECK (namedLabel (*editor, "Processing status")->getText() == "No audio"); } -TEST_CASE ("declared host scale factors preserve logical layout and render cleanly") +TEST_CASE ("sidechain levels are excluded and clipping is visible and accessible") { juce::ScopedJuceInitialiser_GUI gui; - const std::array scales { 1.0f, 1.25f, 1.5f, 1.75f, 2.0f }; + SuppressorProcessor p; + auto layout = p.getBusesLayout(); + layout.inputBuses.set (1, juce::AudioChannelSet::stereo()); + REQUIRE (p.setBusesLayout (layout)); + prepare (p); + SuppressorEditor editor (p); + editor.addToDesktop (0); + editor.setVisible (true); + juce::AudioBuffer buffer (4, 256); + juce::MidiBuffer midi; + buffer.clear(); + buffer.setSample (0, 0, .25f); + buffer.setSample (2, 0, 4.0f); + p.processBlock (buffer, midi); + suppressor::MeterSnapshot meters; + REQUIRE (p.readMeters (meters)); + CHECK (meters.input == doctest::Approx (.25f)); + CHECK ((meters.flags & suppressor::MeterSnapshot::inputClip) == 0); + buffer.clear(); + buffer.setSample (1, 0, 1.25f); + p.processBlockBypassed (buffer, midi); + pump(); + auto* summary = namedLabel (editor, "Signal meters"); + REQUIRE (summary != nullptr); + CHECK (waitFor ([&] { return summary->getDescription().contains ("Input clipped") + && summary->getDescription().contains ("Output clipped"); })); + capture (editor, "suppressor-clipping"); + for (float value : { 0.0f, 1.0f }) + { + for (const char* id : { "threshold", "strength", "release" }) + p.apvts.getParameter (id)->setValueNotifyingHost (value); + pump(); + capture (editor, value > .5f ? "suppressor-maximum" : "suppressor-minimum"); + } +} - for (const auto scale : scales) +TEST_CASE ("VST3 native editor lifecycle and host parameter state recall") +{ + const auto path = juce::SystemStats::getEnvironmentVariable ("SUPPRESSOR_VST3_PATH", {}); + if (path.isEmpty()) return; + juce::ScopedJuceInitialiser_GUI gui; + juce::VST3PluginFormat format; + juce::OwnedArray types; + format.findAllTypesForFile (types, path); + REQUIRE (types.size() == 1); + juce::String errorMessage; + auto p = format.createInstanceFromDescription (*types[0], 48000, 256, errorMessage); + INFO (errorMessage); + REQUIRE (p != nullptr); + prepare (*p); + juce::Component hostWindow; + hostWindow.setBounds (80, 80, 640, 460); + hostWindow.addToDesktop (0); + hostWindow.setVisible (true); + pump(); + for (int reopen = 0; reopen < 3; ++reopen) { - 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); + std::unique_ptr editor (p->createEditorIfNeeded()); + REQUIRE (editor != nullptr); + // Exercise embedding in the host's native window, rather than making + // the VST3 adapter itself the top-level window. + hostWindow.addAndMakeVisible (*editor); + pump(); + feed (*p); pump(); + CHECK (editor->getWidth() == 640); + CHECK (editor->getHeight() == 460); + // This is the host's native embedding view, not the plugin's JUCE + // component tree. Exercise the real wrapper rather than casting it. + REQUIRE (p->getParameters().size() == 21); // includes VST3 wrapper bypass + auto* parameter = p->getParameters()[1]; + CHECK (parameter->getName (64) == "Threshold"); + parameter->setValueNotifyingHost (.6f); + feed (*p); pump(); + juce::MemoryBlock state; + p->getStateInformation (state); + const auto saved = parameter->getValue(); + parameter->setValueNotifyingHost (.8f); + feed (*p); pump(); + CHECK (parameter->getValue() == doctest::Approx (.8f)); + p->setStateInformation (state.getData(), static_cast (state.getSize())); + feed (*p); pump(); + CHECK (parameter->getValue() == doctest::Approx (saved)); + editor->setVisible (false); + pump(); 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); + pump(); } + p->releaseResources(); } diff --git a/Tests/test_ui_main.cpp b/Tests/test_ui_main.cpp new file mode 100644 index 0000000..10ba639 --- /dev/null +++ b/Tests/test_ui_main.cpp @@ -0,0 +1,33 @@ +#define DOCTEST_CONFIG_IMPLEMENT +#include +#include + +// Native embedding needs an actual host application, not only a message-thread +// initializer. In particular JUCE's XEmbed host makes an initial property query +// before the client window exists; JUCEApplication owns the platform event/error +// handling for that lifecycle, just as AudioPluginHost and pluginval do. +class UITestApplication final : public juce::JUCEApplication +{ +public: + const juce::String getApplicationName() override { return "Suppressor UI tests"; } + const juce::String getApplicationVersion() override { return "1.0"; } + bool moreThanOneInstanceAllowed() override { return true; } + + void initialise (const juce::String&) override + { + juce::MessageManager::callAsync ([this] + { + auto arguments = getCommandLineParameterArray(); + arguments.insert (0, getApplicationName()); + std::vector argv; + for (const auto& argument : arguments) argv.push_back (argument.toRawUTF8()); + doctest::Context context (static_cast (argv.size()), argv.data()); + setApplicationReturnValue (context.run()); + quit(); + }); + } + + void shutdown() override {} +}; + +START_JUCE_APPLICATION (UITestApplication) diff --git a/docs/images/suppressor-ui.png b/docs/images/suppressor-ui.png index 17e2614..eac0e87 100644 Binary files a/docs/images/suppressor-ui.png and b/docs/images/suppressor-ui.png differ