commit a587a43ff94e5b1e8e76b28b5a73b6589b9f8ab2 Author: hariel1985 Date: Fri Mar 27 16:03:24 2026 +0100 Initial release — InstaShadow mastering compressor v1.0 Dual-stage compressor (optical + VCA) with output transformer saturation. - Port-Hamiltonian T4B opto-cell model with implicit trapezoidal integration - Feed-forward VCA with 7 ratios, 6 attack/release presets, Dual release mode - 3 transformer types (Nickel/Iron/Steel) with 4x oversampled waveshaping - Analog-style needle VU meters, horizontal GR meters - Sidechain HPF, stereo link, independent section bypass - Full state save/restore, CI/CD for Windows/macOS/Linux diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..eba7272 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,130 @@ +name: Build InstaShadow + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + +jobs: + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Clone JUCE + run: git clone --depth 1 https://github.com/juce-framework/JUCE.git ../JUCE + + - name: Configure CMake + run: cmake -B build -G "Visual Studio 17 2022" -A x64 + + - name: Build Release + run: cmake --build build --config Release + + - name: Package VST3 + run: Compress-Archive -Path "build/InstaShadow_artefacts/Release/VST3/InstaShadow.vst3" -DestinationPath "InstaShadow-VST3-Win64.zip" + + - name: Upload VST3 + uses: actions/upload-artifact@v4 + with: + name: InstaShadow-VST3-Win64 + path: InstaShadow-VST3-Win64.zip + + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Clone JUCE + run: git clone --depth 1 https://github.com/juce-framework/JUCE.git ../JUCE + + - name: Configure CMake (Universal Binary) + run: cmake -B build -G Xcode -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" -DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 + + - name: Build Release + run: cmake --build build --config Release + + - name: Package VST3 + working-directory: build/InstaShadow_artefacts/Release + run: zip -r $GITHUB_WORKSPACE/InstaShadow-VST3-macOS.zip VST3/InstaShadow.vst3 + + - name: Package AU + working-directory: build/InstaShadow_artefacts/Release + run: zip -r $GITHUB_WORKSPACE/InstaShadow-AU-macOS.zip AU/InstaShadow.component + + - name: Upload VST3 + uses: actions/upload-artifact@v4 + with: + name: InstaShadow-VST3-macOS + path: InstaShadow-VST3-macOS.zip + + - name: Upload AU + uses: actions/upload-artifact@v4 + with: + name: InstaShadow-AU-macOS + path: InstaShadow-AU-macOS.zip + + build-linux: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake git libasound2-dev \ + libfreetype6-dev libx11-dev libxrandr-dev libxcursor-dev \ + libxinerama-dev libwebkit2gtk-4.1-dev libcurl4-openssl-dev + + - name: Clone JUCE + run: git clone --depth 1 https://github.com/juce-framework/JUCE.git ../JUCE + + - name: Configure CMake + run: cmake -B build -DCMAKE_BUILD_TYPE=Release + + - name: Build Release + run: cmake --build build --config Release --parallel $(nproc) + + - name: Package VST3 + working-directory: build/InstaShadow_artefacts/Release + run: zip -r $GITHUB_WORKSPACE/InstaShadow-VST3-Linux-x64.zip VST3/InstaShadow.vst3 + + - name: Package LV2 + working-directory: build/InstaShadow_artefacts/Release + run: zip -r $GITHUB_WORKSPACE/InstaShadow-LV2-Linux-x64.zip LV2/InstaShadow.lv2 + + - name: Upload VST3 + uses: actions/upload-artifact@v4 + with: + name: InstaShadow-VST3-Linux-x64 + path: InstaShadow-VST3-Linux-x64.zip + + - name: Upload LV2 + uses: actions/upload-artifact@v4 + with: + name: InstaShadow-LV2-Linux-x64 + path: InstaShadow-LV2-Linux-x64.zip + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [build-windows, build-macos, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/InstaShadow-VST3-Win64/InstaShadow-VST3-Win64.zip + artifacts/InstaShadow-VST3-macOS/InstaShadow-VST3-macOS.zip + artifacts/InstaShadow-AU-macOS/InstaShadow-AU-macOS.zip + artifacts/InstaShadow-VST3-Linux-x64/InstaShadow-VST3-Linux-x64.zip + artifacts/InstaShadow-LV2-Linux-x64/InstaShadow-LV2-Linux-x64.zip + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..3a8bc8d --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,64 @@ +cmake_minimum_required(VERSION 3.22) +project(InstaShadow VERSION 1.0.0) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../JUCE ${CMAKE_CURRENT_BINARY_DIR}/JUCE) + +juce_add_plugin(InstaShadow + COMPANY_NAME "InstaShadow" + IS_SYNTH FALSE + NEEDS_MIDI_INPUT FALSE + NEEDS_MIDI_OUTPUT FALSE + PLUGIN_MANUFACTURER_CODE Inst + PLUGIN_CODE Ishd + FORMATS VST3 AU LV2 + LV2URI "https://github.com/hariel1985/InstaShadow" + PRODUCT_NAME "InstaShadow" + COPY_PLUGIN_AFTER_BUILD FALSE +) + +juce_generate_juce_header(InstaShadow) + +juce_add_binary_data(InstaShadowData SOURCES + Resources/Rajdhani-Regular.ttf + Resources/Rajdhani-Medium.ttf + Resources/Rajdhani-Bold.ttf +) + +target_sources(InstaShadow + PRIVATE + Source/PluginProcessor.cpp + Source/PluginEditor.cpp + Source/LookAndFeel.cpp + Source/CompressorEngine.cpp + Source/OpticalCell.cpp + Source/VCACompressor.cpp + Source/TransformerSaturation.cpp + Source/OpticalPanel.cpp + Source/DiscretePanel.cpp + Source/TransformerPanel.cpp + Source/OutputPanel.cpp +) + +target_compile_definitions(InstaShadow + PUBLIC + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 + JUCE_VST3_CAN_REPLACE_VST2=0 +) + +target_link_libraries(InstaShadow + PRIVATE + InstaShadowData + juce::juce_audio_basics + juce::juce_audio_devices + juce::juce_audio_formats + juce::juce_audio_processors + juce::juce_audio_utils + juce::juce_dsp + PUBLIC + juce::juce_recommended_config_flags + juce::juce_recommended_warning_flags +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6838fb1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + For the full license text, see diff --git a/README.md b/README.md new file mode 100644 index 0000000..74abce1 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# InstaShadow + +Dual-stage mastering compressor plugin (VST3/AU/LV2) inspired by the Shadow Hills Mastering Compressor, built with JUCE. + +## Features + +- **Optical Compressor** — Port-Hamiltonian T4B opto-cell model with physically accurate two-stage release and CdS memory effect +- **Discrete VCA Compressor** — Feed-forward Class-A topology with 7 ratio settings (1.2:1 to Flood), 6 attack/release presets, and Dual release mode +- **Output Transformer** — 3 switchable transformer types (Nickel/Iron/Steel) with frequency-dependent saturation and 4x oversampling +- **Sidechain HPF** — Variable 20-500 Hz high-pass filter to prevent bass-induced pumping +- **Stereo Link** — Linked or dual-mono operation +- **Independent bypass** — Each section can be bypassed separately +- **GR Metering** — Dedicated optical and discrete gain reduction meters +- **State save/restore** — All parameters persist with DAW session + +## Signal Flow + +``` +Input → SC HPF → Optical Comp (T4B) → VCA Comp → Transformer → Output +``` + +## Build + +Requires [JUCE](https://github.com/juce-framework/JUCE) cloned at `../JUCE` relative to this project. + +```bash +cmake -B build -G "Visual Studio 17 2022" -A x64 +cmake --build build --config Release +``` + +## License + +GPL-3.0 diff --git a/Resources/Rajdhani-Bold.ttf b/Resources/Rajdhani-Bold.ttf new file mode 100644 index 0000000..47af157 Binary files /dev/null and b/Resources/Rajdhani-Bold.ttf differ diff --git a/Resources/Rajdhani-Medium.ttf b/Resources/Rajdhani-Medium.ttf new file mode 100644 index 0000000..a6960c6 Binary files /dev/null and b/Resources/Rajdhani-Medium.ttf differ diff --git a/Resources/Rajdhani-Regular.ttf b/Resources/Rajdhani-Regular.ttf new file mode 100644 index 0000000..d25bd37 Binary files /dev/null and b/Resources/Rajdhani-Regular.ttf differ diff --git a/Source/CompressorEngine.cpp b/Source/CompressorEngine.cpp new file mode 100644 index 0000000..75a17f3 --- /dev/null +++ b/Source/CompressorEngine.cpp @@ -0,0 +1,145 @@ +#include "CompressorEngine.h" + +CompressorEngine::CompressorEngine() {} + +void CompressorEngine::prepare (double sampleRate, int samplesPerBlock) +{ + currentSampleRate = sampleRate; + + for (auto& cell : optoCells) cell.prepare (sampleRate); + for (auto& comp : vcaComps) comp.prepare (sampleRate); + transformer.prepare (sampleRate, samplesPerBlock); + + // Init sidechain HPF + lastScHpfFreq = 0.0f; + updateScHpf (90.0f); + + for (auto& f : scHpfFilters) f.reset(); +} + +void CompressorEngine::updateScHpf (float freqHz) +{ + if (std::abs (freqHz - lastScHpfFreq) < 0.1f) return; + lastScHpfFreq = freqHz; + + auto coeffs = juce::dsp::IIR::Coefficients::makeHighPass (currentSampleRate, freqHz); + for (auto& f : scHpfFilters) + f.coefficients = coeffs; +} + +void CompressorEngine::processBlock (juce::AudioBuffer& buffer) +{ + const int numSamples = buffer.getNumSamples(); + const int numChannels = std::min (buffer.getNumChannels(), 2); + + if (globalBypass.load() || numChannels == 0) return; + + // Read parameters once per block + float optoThresh = optoThresholdDb.load(); + float optoGain = optoGainDb.load(); + float scHpf = optoScHpfHz.load(); + bool optoBp = optoBypass.load(); + + float vcaThresh = vcaThresholdDb.load(); + float vcaGain = vcaGainDb.load(); + int ratioIdx = std::clamp (vcaRatioIndex.load(), 0, numRatios - 1); + int attackIdx = std::clamp (vcaAttackIndex.load(), 0, numAttacks - 1); + int releaseIdx = std::clamp (vcaReleaseIndex.load(), 0, numReleases - 1); + bool vcaBp = vcaBypass.load(); + + int xfmrType = transformerType.load(); + float outGain = std::pow (10.0f, outputGainDb.load() / 20.0f); + bool linked = stereoLink.load(); + + float ratio = ratios[ratioIdx]; + float attackSec = attacks[attackIdx]; + float releaseSec = releases[releaseIdx]; + bool dualRel = (releaseSec < 0.0f); + if (dualRel) releaseSec = 0.5f; // fallback (not used in dual mode) + + float optoThreshLin = std::pow (10.0f, optoThresh / 20.0f); + float optoGainLin = std::pow (10.0f, optoGain / 20.0f); + float vcaGainLin = std::pow (10.0f, vcaGain / 20.0f); + + // Update sidechain HPF + updateScHpf (scHpf); + + // Per-sample processing + float peakOptoGr = 0.0f; + float peakVcaGr = 0.0f; + + for (int i = 0; i < numSamples; ++i) + { + // Get input samples + float inL = buffer.getSample (0, i); + float inR = (numChannels > 1) ? buffer.getSample (1, i) : inL; + + // Sidechain: HPF filtered input for detection + float scL = scHpfFilters[0].processSample (inL); + float scR = (numChannels > 1) ? scHpfFilters[1].processSample (inR) : scL; + + // Sidechain level (rectified) + float scLevelL = std::abs (scL); + float scLevelR = std::abs (scR); + + if (linked) + { + float scMono = (scLevelL + scLevelR) * 0.5f; + scLevelL = scMono; + scLevelR = scMono; + } + + // --- Optical compressor --- + float optoGainL = 1.0f, optoGainR = 1.0f; + if (! optoBp) + { + optoGainL = optoCells[0].processSample (scLevelL, optoThreshLin); + optoGainR = linked ? optoGainL : optoCells[1].processSample (scLevelR, optoThreshLin); + + inL *= optoGainL * optoGainLin; + inR *= optoGainR * optoGainLin; + + float gr = optoCells[0].getGainReductionDb(); + if (gr < peakOptoGr) peakOptoGr = gr; + } + + // --- VCA compressor --- + if (! vcaBp) + { + // Convert to dB for VCA + float scDbL = 20.0f * std::log10 (std::max (1.0e-6f, std::abs (inL))); + float scDbR = 20.0f * std::log10 (std::max (1.0e-6f, std::abs (inR))); + + if (linked) scDbL = scDbR = std::max (scDbL, scDbR); + + float vcaGainL = vcaComps[0].processSample (scDbL, vcaThresh, ratio, attackSec, releaseSec, dualRel); + float vcaGainR = linked ? vcaGainL : vcaComps[1].processSample (scDbR, vcaThresh, ratio, attackSec, releaseSec, dualRel); + + inL *= vcaGainL * vcaGainLin; + inR *= vcaGainR * vcaGainLin; + + float gr = vcaComps[0].getGainReductionDb(); + if (gr < peakVcaGr) peakVcaGr = gr; + } + + // Write back (transformer and output gain applied later as block ops) + buffer.setSample (0, i, inL); + if (numChannels > 1) buffer.setSample (1, i, inR); + } + + // --- Transformer saturation (block-level, 4x oversampled) --- + if (xfmrType > 0 && xfmrType <= 3) + transformer.processBlock (buffer, (TransformerSaturation::Type) xfmrType); + + // --- Output gain --- + buffer.applyGain (outGain); + + // --- Metering --- + optoGrDb.store (peakOptoGr); + vcaGrDb.store (peakVcaGr); + outputLevelL.store (buffer.getMagnitude (0, 0, numSamples)); + if (numChannels > 1) + outputLevelR.store (buffer.getMagnitude (1, 0, numSamples)); + else + outputLevelR.store (outputLevelL.load()); +} diff --git a/Source/CompressorEngine.h b/Source/CompressorEngine.h new file mode 100644 index 0000000..561fb77 --- /dev/null +++ b/Source/CompressorEngine.h @@ -0,0 +1,64 @@ +#pragma once +#include +#include "OpticalCell.h" +#include "VCACompressor.h" +#include "TransformerSaturation.h" + +class CompressorEngine +{ +public: + CompressorEngine(); + + void prepare (double sampleRate, int samplesPerBlock); + void processBlock (juce::AudioBuffer& buffer); + + // --- Optical section (GUI → audio) --- + std::atomic optoThresholdDb { -20.0f }; + std::atomic optoGainDb { 0.0f }; + std::atomic optoScHpfHz { 90.0f }; + std::atomic optoBypass { false }; + + // --- Discrete VCA section --- + std::atomic vcaThresholdDb { -20.0f }; + std::atomic vcaGainDb { 0.0f }; + std::atomic vcaRatioIndex { 1 }; + std::atomic vcaAttackIndex { 2 }; + std::atomic vcaReleaseIndex { 2 }; + std::atomic vcaBypass { false }; + + // --- Transformer --- + std::atomic transformerType { 0 }; // 0=Off, 1=Nickel, 2=Iron, 3=Steel + + // --- Output / Global --- + std::atomic outputGainDb { 0.0f }; + std::atomic stereoLink { true }; + std::atomic globalBypass { false }; + + // --- Metering (audio → GUI) --- + std::atomic optoGrDb { 0.0f }; + std::atomic vcaGrDb { 0.0f }; + std::atomic outputLevelL { 0.0f }; + std::atomic outputLevelR { 0.0f }; + + // Lookup tables + static constexpr float ratios[] = { 1.2f, 2.0f, 3.0f, 4.0f, 6.0f, 10.0f, 20.0f }; + static constexpr float attacks[] = { 0.0001f, 0.0005f, 0.001f, 0.005f, 0.01f, 0.03f }; + static constexpr float releases[] = { 0.1f, 0.25f, 0.5f, 0.8f, 1.2f, -1.0f }; // -1 = Dual + static constexpr int numRatios = 7; + static constexpr int numAttacks = 6; + static constexpr int numReleases = 6; + +private: + double currentSampleRate = 44100.0; + + // Per-channel DSP + std::array optoCells; + std::array vcaComps; + TransformerSaturation transformer; + + // Sidechain HPF (2nd order, per channel) + std::array, 2> scHpfFilters; + float lastScHpfFreq = 0.0f; + + void updateScHpf (float freqHz); +}; diff --git a/Source/DiscretePanel.cpp b/Source/DiscretePanel.cpp new file mode 100644 index 0000000..48cc3f3 --- /dev/null +++ b/Source/DiscretePanel.cpp @@ -0,0 +1,129 @@ +#include "DiscretePanel.h" + +DiscretePanel::DiscretePanel() +{ + titleLabel.setText ("DISCRETE", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centred); + titleLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textPrimary); + addAndMakeVisible (titleLabel); + + auto setupKnob = [this] (juce::Slider& knob, juce::Label& label, const juce::String& name, + double min, double max, double def, const juce::String& suffix) + { + knob.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + knob.setTextBoxStyle (juce::Slider::TextBoxBelow, false, 60, 14); + knob.setRange (min, max); + knob.setValue (def); + knob.setTextValueSuffix (suffix); + knob.getProperties().set (InstaShadowLookAndFeel::knobTypeProperty, "orange"); + addAndMakeVisible (knob); + + label.setText (name, juce::dontSendNotification); + label.setJustificationType (juce::Justification::centred); + label.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textSecondary); + addAndMakeVisible (label); + }; + + setupKnob (thresholdKnob, threshLabel, "Threshold", -40.0, 0.0, -20.0, " dB"); + setupKnob (gainKnob, gainLabel, "Gain", 0.0, 20.0, 0.0, " dB"); + + ratioBox.addItem ("1.2:1", 1); + ratioBox.addItem ("2:1", 2); + ratioBox.addItem ("3:1", 3); + ratioBox.addItem ("4:1", 4); + ratioBox.addItem ("6:1", 5); + ratioBox.addItem ("10:1", 6); + ratioBox.addItem ("Flood", 7); + ratioBox.setSelectedId (2); + addAndMakeVisible (ratioBox); + ratioLabel.setText ("Ratio", juce::dontSendNotification); + ratioLabel.setJustificationType (juce::Justification::centred); + ratioLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textSecondary); + addAndMakeVisible (ratioLabel); + + attackBox.addItem ("0.1 ms", 1); + attackBox.addItem ("0.5 ms", 2); + attackBox.addItem ("1 ms", 3); + attackBox.addItem ("5 ms", 4); + attackBox.addItem ("10 ms", 5); + attackBox.addItem ("30 ms", 6); + attackBox.setSelectedId (3); + addAndMakeVisible (attackBox); + attackLabel.setText ("Attack", juce::dontSendNotification); + attackLabel.setJustificationType (juce::Justification::centred); + attackLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textSecondary); + addAndMakeVisible (attackLabel); + + releaseBox.addItem ("100 ms", 1); + releaseBox.addItem ("250 ms", 2); + releaseBox.addItem ("500 ms", 3); + releaseBox.addItem ("800 ms", 4); + releaseBox.addItem ("1.2 s", 5); + releaseBox.addItem ("Dual", 6); + releaseBox.setSelectedId (3); + addAndMakeVisible (releaseBox); + releaseLabel.setText ("Release", juce::dontSendNotification); + releaseLabel.setJustificationType (juce::Justification::centred); + releaseLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textSecondary); + addAndMakeVisible (releaseLabel); + + bypassToggle.setButtonText (""); + addAndMakeVisible (bypassToggle); + bypassLabel.setText ("Bypass", juce::dontSendNotification); + bypassLabel.setJustificationType (juce::Justification::centred); + bypassLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textSecondary); + addAndMakeVisible (bypassLabel); +} + +void DiscretePanel::resized() +{ + auto bounds = getLocalBounds().reduced (4); + titleLabel.setBounds (bounds.removeFromTop (18)); + + int availH = bounds.getHeight() - 22; // reserve for bypass + + // Top: 2 knobs stacked vertically (~55%) + int knobSectionH = (int) (availH * 0.55f); + int singleKnobH = knobSectionH / 2; + + auto k1 = bounds.removeFromTop (singleKnobH); + thresholdKnob.setBounds (k1.withTrimmedBottom (14).reduced (4, 0)); + threshLabel.setBounds (k1.getX(), k1.getBottom() - 14, k1.getWidth(), 14); + + auto k2 = bounds.removeFromTop (singleKnobH); + gainKnob.setBounds (k2.withTrimmedBottom (14).reduced (4, 0)); + gainLabel.setBounds (k2.getX(), k2.getBottom() - 14, k2.getWidth(), 14); + + // Gap between knobs and combos + bounds.removeFromTop (6); + + // Middle: 3 combos stacked (~30%) + int comboSectionH = (int) (availH * 0.30f); + int comboH = comboSectionH / 3; + + auto r1 = bounds.removeFromTop (comboH); + ratioLabel.setBounds (r1.removeFromLeft (r1.getWidth() / 3)); + ratioBox.setBounds (r1.reduced (2, 1)); + + auto r2 = bounds.removeFromTop (comboH); + attackLabel.setBounds (r2.removeFromLeft (r2.getWidth() / 3)); + attackBox.setBounds (r2.reduced (2, 1)); + + auto r3 = bounds.removeFromTop (comboH); + releaseLabel.setBounds (r3.removeFromLeft (r3.getWidth() / 3)); + releaseBox.setBounds (r3.reduced (2, 1)); + + // Bottom: bypass + auto bpRow = bounds; + bypassLabel.setBounds (bpRow.removeFromLeft (bpRow.getWidth() / 2)); + bypassToggle.setBounds (bpRow.reduced (4, 2)); +} + +void DiscretePanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaShadowLookAndFeel::bgMedium.withAlpha (0.5f)); + g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (InstaShadowLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} diff --git a/Source/DiscretePanel.h b/Source/DiscretePanel.h new file mode 100644 index 0000000..6d65c3d --- /dev/null +++ b/Source/DiscretePanel.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include "LookAndFeel.h" + +class DiscretePanel : public juce::Component +{ +public: + DiscretePanel(); + void resized() override; + void paint (juce::Graphics& g) override; + + juce::Slider thresholdKnob; + juce::Slider gainKnob; + juce::ComboBox ratioBox; + juce::ComboBox attackBox; + juce::ComboBox releaseBox; + juce::ToggleButton bypassToggle; + +private: + juce::Label titleLabel; + juce::Label threshLabel, gainLabel, ratioLabel, attackLabel, releaseLabel, bypassLabel; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (DiscretePanel) +}; diff --git a/Source/GRMeter.h b/Source/GRMeter.h new file mode 100644 index 0000000..32027c4 --- /dev/null +++ b/Source/GRMeter.h @@ -0,0 +1,59 @@ +#pragma once +#include + +class GRMeter : public juce::Component +{ +public: + void setGainReduction (float grDb) + { + float clamped = juce::jlimit (-30.0f, 0.0f, grDb); + float normalised = -clamped / 30.0f; + currentGr = std::max (normalised, currentGr * 0.92f); + if (normalised > peakGr) peakGr = normalised; + else peakGr *= 0.998f; + repaint(); + } + + void setBarColour (juce::Colour c) { barColour = c; } + void setLabel (const juce::String& text) { label = text; } + + void paint (juce::Graphics& g) override + { + auto bounds = getLocalBounds().toFloat().reduced (1); + + // Background + g.setColour (juce::Colour (0xff111122)); + g.fillRoundedRectangle (bounds, 2.0f); + + // GR bar (fills from right to left) + float w = bounds.getWidth() * currentGr; + auto filled = bounds.withLeft (bounds.getRight() - w); + g.setColour (barColour); + g.fillRoundedRectangle (filled, 2.0f); + + // Peak hold line + if (peakGr > 0.01f) + { + float peakX = bounds.getRight() - bounds.getWidth() * peakGr; + g.setColour (juce::Colours::white.withAlpha (0.8f)); + g.fillRect (peakX, bounds.getY(), 1.5f, bounds.getHeight()); + } + + // Label + g.setColour (juce::Colour (0xffe0e0e0).withAlpha (0.7f)); + g.setFont (11.0f); + g.drawText (label, bounds.reduced (4, 0), juce::Justification::centredLeft); + + // dB readout + float dbVal = -currentGr * 30.0f; + if (currentGr > 0.001f) + g.drawText (juce::String (dbVal, 1) + " dB", bounds.reduced (4, 0), + juce::Justification::centredRight); + } + +private: + float currentGr = 0.0f; + float peakGr = 0.0f; + juce::Colour barColour { 0xffff8833 }; + juce::String label; +}; diff --git a/Source/LookAndFeel.cpp b/Source/LookAndFeel.cpp new file mode 100644 index 0000000..47358c7 --- /dev/null +++ b/Source/LookAndFeel.cpp @@ -0,0 +1,372 @@ +#include "LookAndFeel.h" +#include "BinaryData.h" + +InstaShadowLookAndFeel::InstaShadowLookAndFeel() +{ + typefaceRegular = juce::Typeface::createSystemTypefaceFor ( + BinaryData::RajdhaniRegular_ttf, BinaryData::RajdhaniRegular_ttfSize); + typefaceMedium = juce::Typeface::createSystemTypefaceFor ( + BinaryData::RajdhaniMedium_ttf, BinaryData::RajdhaniMedium_ttfSize); + typefaceBold = juce::Typeface::createSystemTypefaceFor ( + BinaryData::RajdhaniBold_ttf, BinaryData::RajdhaniBold_ttfSize); + + setColour (juce::ResizableWindow::backgroundColourId, bgDark); + setColour (juce::Label::textColourId, textPrimary); + setColour (juce::TextButton::buttonColourId, bgMedium); + setColour (juce::TextButton::textColourOffId, textPrimary); + setColour (juce::ComboBox::backgroundColourId, bgMedium); + setColour (juce::ComboBox::textColourId, textPrimary); + setColour (juce::ComboBox::outlineColourId, bgLight); + setColour (juce::PopupMenu::backgroundColourId, bgMedium); + setColour (juce::PopupMenu::textColourId, textPrimary); + setColour (juce::PopupMenu::highlightedBackgroundColourId, bgLight); + + generateNoiseTexture(); +} + +juce::Typeface::Ptr InstaShadowLookAndFeel::getTypefaceForFont (const juce::Font& font) +{ + if (font.isBold()) + return typefaceBold; + return typefaceRegular; +} + +juce::Font InstaShadowLookAndFeel::getRegularFont (float height) const +{ + return juce::Font (juce::FontOptions (typefaceRegular).withHeight (height)); +} + +juce::Font InstaShadowLookAndFeel::getMediumFont (float height) const +{ + return juce::Font (juce::FontOptions (typefaceMedium).withHeight (height)); +} + +juce::Font InstaShadowLookAndFeel::getBoldFont (float height) const +{ + return juce::Font (juce::FontOptions (typefaceBold).withHeight (height)); +} + +void InstaShadowLookAndFeel::generateNoiseTexture() +{ + const int texW = 256, texH = 256; + noiseTexture = juce::Image (juce::Image::ARGB, texW, texH, true); + + juce::Random rng (42); + + for (int y = 0; y < texH; ++y) + { + for (int x = 0; x < texW; ++x) + { + float noise = rng.nextFloat() * 0.06f; + bool crossA = ((x + y) % 4 == 0); + bool crossB = ((x - y + 256) % 4 == 0); + float pattern = (crossA || crossB) ? 0.03f : 0.0f; + float alpha = noise + pattern; + noiseTexture.setPixelAt (x, y, juce::Colour::fromFloatRGBA (1.0f, 1.0f, 1.0f, alpha)); + } + } +} + +void InstaShadowLookAndFeel::drawBackgroundTexture (juce::Graphics& g, juce::Rectangle area) +{ + for (int y = area.getY(); y < area.getBottom(); y += noiseTexture.getHeight()) + for (int x = area.getX(); x < area.getRight(); x += noiseTexture.getWidth()) + g.drawImageAt (noiseTexture, x, y); +} + +// ============================================================ +// Rotary slider (3D metal knob) +// ============================================================ + +void InstaShadowLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float rotaryStartAngle, + float rotaryEndAngle, juce::Slider& slider) +{ + float knobSize = std::min ((float) width, (float) height); + float s = knobSize / 60.0f; + float margin = std::max (4.0f, 6.0f * s); + + auto bounds = juce::Rectangle (x, y, width, height).toFloat().reduced (margin); + auto radius = std::min (bounds.getWidth(), bounds.getHeight()) / 2.0f; + auto cx = bounds.getCentreX(); + auto cy = bounds.getCentreY(); + auto angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle); + + auto knobType = slider.getProperties() [knobTypeProperty].toString(); + bool isDark = (knobType == "dark"); + + juce::Colour arcColour = isDark ? juce::Colour (0xff4488ff) : juce::Colour (0xffff8833); + juce::Colour arcBgColour = isDark ? juce::Colour (0xff1a2a44) : juce::Colour (0xff2a1a0a); + juce::Colour bodyTop = isDark ? juce::Colour (0xff3a3a4a) : juce::Colour (0xff5a4a3a); + juce::Colour bodyBottom = isDark ? juce::Colour (0xff1a1a2a) : juce::Colour (0xff2a1a0a); + juce::Colour rimColour = isDark ? juce::Colour (0xff555566) : juce::Colour (0xff886644); + juce::Colour highlightCol = isDark ? juce::Colour (0x33aabbff) : juce::Colour (0x44ffcc88); + juce::Colour pointerColour = isDark ? juce::Colour (0xff66aaff) : juce::Colour (0xffffaa44); + + float arcW = std::max (1.5f, 2.5f * s); + float glowW1 = std::max (3.0f, 10.0f * s); + float hotW = std::max (0.8f, 1.2f * s); + float ptrW = std::max (1.2f, 2.0f * s); + float bodyRadius = radius * 0.72f; + + // 1. Drop shadow + g.setColour (juce::Colours::black.withAlpha (0.35f)); + g.fillEllipse (cx - bodyRadius + 1, cy - bodyRadius + 2, bodyRadius * 2, bodyRadius * 2); + + // 2. Outer arc track + { + juce::Path arcBg; + arcBg.addCentredArc (cx, cy, radius - 1, radius - 1, 0.0f, + rotaryStartAngle, rotaryEndAngle, true); + g.setColour (arcBgColour); + g.strokePath (arcBg, juce::PathStrokeType (arcW, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + } + + // 3. Outer arc value with glow + if (sliderPos > 0.01f) + { + juce::Path arcVal; + arcVal.addCentredArc (cx, cy, radius - 1, radius - 1, 0.0f, + rotaryStartAngle, angle, true); + + const int numGlowLayers = 8; + for (int i = 0; i < numGlowLayers; ++i) + { + float t = (float) i / (float) (numGlowLayers - 1); + float layerWidth = glowW1 * (1.0f - t * 0.7f); + float layerAlpha = 0.03f + t * t * 0.35f; + g.setColour (arcColour.withAlpha (layerAlpha)); + g.strokePath (arcVal, juce::PathStrokeType (layerWidth, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + } + + g.setColour (arcColour); + g.strokePath (arcVal, juce::PathStrokeType (arcW, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + + g.setColour (arcColour.brighter (0.6f).withAlpha (0.5f)); + g.strokePath (arcVal, juce::PathStrokeType (hotW, juce::PathStrokeType::curved, + juce::PathStrokeType::rounded)); + } + + // 4. Knob body + { + juce::ColourGradient bodyGrad (bodyTop, cx, cy - bodyRadius * 0.5f, + bodyBottom, cx, cy + bodyRadius, true); + g.setGradientFill (bodyGrad); + g.fillEllipse (cx - bodyRadius, cy - bodyRadius, bodyRadius * 2, bodyRadius * 2); + } + + // 5. Rim + g.setColour (rimColour.withAlpha (0.6f)); + g.drawEllipse (cx - bodyRadius, cy - bodyRadius, bodyRadius * 2, bodyRadius * 2, std::max (0.8f, 1.2f * s)); + + // 6. Inner shadow + { + float innerR = bodyRadius * 0.85f; + juce::ColourGradient innerGrad (juce::Colours::black.withAlpha (0.15f), cx, cy - innerR * 0.3f, + juce::Colours::transparentBlack, cx, cy + innerR, true); + g.setGradientFill (innerGrad); + g.fillEllipse (cx - innerR, cy - innerR, innerR * 2, innerR * 2); + } + + // 7. Top highlight + { + float hlRadius = bodyRadius * 0.55f; + float hlY = cy - bodyRadius * 0.35f; + juce::ColourGradient hlGrad (highlightCol, cx, hlY - hlRadius * 0.5f, + juce::Colours::transparentBlack, cx, hlY + hlRadius, true); + g.setGradientFill (hlGrad); + g.fillEllipse (cx - hlRadius, hlY - hlRadius * 0.6f, hlRadius * 2, hlRadius * 1.2f); + } + + // 8. Pointer with glow + { + float pointerLen = bodyRadius * 0.75f; + + for (int i = 0; i < 4; ++i) + { + float t = (float) i / 3.0f; + float gw = ptrW * (2.0f - t * 1.5f); + float alpha = 0.02f + t * t * 0.15f; + + juce::Path glowLayer; + glowLayer.addRoundedRectangle (-gw, -pointerLen, gw * 2, pointerLen * 0.55f, gw * 0.5f); + glowLayer.applyTransform (juce::AffineTransform::rotation (angle).translated (cx, cy)); + g.setColour (pointerColour.withAlpha (alpha)); + g.fillPath (glowLayer); + } + + { + juce::Path pointer; + pointer.addRoundedRectangle (-ptrW * 0.5f, -pointerLen, ptrW, pointerLen * 0.55f, ptrW * 0.5f); + pointer.applyTransform (juce::AffineTransform::rotation (angle).translated (cx, cy)); + g.setColour (pointerColour); + g.fillPath (pointer); + } + + { + juce::Path hotCenter; + float hw = ptrW * 0.3f; + hotCenter.addRoundedRectangle (-hw, -pointerLen, hw * 2, pointerLen * 0.5f, hw); + hotCenter.applyTransform (juce::AffineTransform::rotation (angle).translated (cx, cy)); + g.setColour (pointerColour.brighter (0.7f).withAlpha (0.6f)); + g.fillPath (hotCenter); + } + } + + // 9. Center cap + { + float capR = bodyRadius * 0.18f; + juce::ColourGradient capGrad (rimColour.brighter (0.3f), cx, cy - capR, + bodyBottom, cx, cy + capR, false); + g.setGradientFill (capGrad); + g.fillEllipse (cx - capR, cy - capR, capR * 2, capR * 2); + } +} + +// ============================================================ +// Button style +// ============================================================ + +void InstaShadowLookAndFeel::drawButtonBackground (juce::Graphics& g, juce::Button& button, + const juce::Colour& backgroundColour, + bool shouldDrawButtonAsHighlighted, + bool shouldDrawButtonAsDown) +{ + auto bounds = button.getLocalBounds().toFloat().reduced (0.5f); + + auto baseColour = backgroundColour; + if (shouldDrawButtonAsDown) + baseColour = baseColour.brighter (0.2f); + else if (shouldDrawButtonAsHighlighted) + baseColour = baseColour.brighter (0.1f); + + juce::ColourGradient grad (baseColour.brighter (0.05f), 0, bounds.getY(), + baseColour.darker (0.1f), 0, bounds.getBottom(), false); + g.setGradientFill (grad); + g.fillRoundedRectangle (bounds, 4.0f); + + g.setColour (bgLight.withAlpha (shouldDrawButtonAsHighlighted ? 0.8f : 0.5f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} + +// ============================================================ +// Toggle button — glowing switch +// ============================================================ + +void InstaShadowLookAndFeel::drawToggleButton (juce::Graphics& g, juce::ToggleButton& button, + bool shouldDrawButtonAsHighlighted, + bool /*shouldDrawButtonAsDown*/) +{ + auto bounds = button.getLocalBounds().toFloat(); + float h = std::min (bounds.getHeight() * 0.6f, 14.0f); + float w = h * 1.8f; + float trackR = h * 0.5f; + + float sx = bounds.getX() + (bounds.getWidth() - w) * 0.5f; + float sy = bounds.getCentreY() - h * 0.5f; + auto trackBounds = juce::Rectangle (sx, sy, w, h); + + bool isOn = button.getToggleState(); + auto onColour = accent; + auto offColour = bgLight; + + float targetPos = isOn ? 1.0f : 0.0f; + float animPos = (float) button.getProperties().getWithDefault ("animPos", targetPos); + animPos += (targetPos - animPos) * 0.25f; + if (std::abs (animPos - targetPos) < 0.01f) animPos = targetPos; + button.getProperties().set ("animPos", animPos); + + if (std::abs (animPos - targetPos) > 0.005f) + button.repaint(); + + float thumbR = h * 0.4f; + float thumbX = sx + trackR + animPos * (w - trackR * 2); + float thumbY = sy + h * 0.5f; + float glowIntensity = animPos; + + if (glowIntensity > 0.01f) + { + for (int i = 0; i < 3; ++i) + { + float t = (float) i / 2.0f; + float expand = (1.0f - t) * 1.5f; + float alpha = (0.04f + t * t * 0.1f) * glowIntensity; + g.setColour (onColour.withAlpha (alpha)); + g.fillRoundedRectangle (trackBounds.expanded (expand), trackR + expand); + } + } + + { + juce::Colour offCol = offColour.withAlpha (0.3f); + juce::Colour onCol = onColour.withAlpha (0.35f); + juce::Colour trackCol = offCol.interpolatedWith (onCol, glowIntensity); + if (shouldDrawButtonAsHighlighted) + trackCol = trackCol.brighter (0.15f); + g.setColour (trackCol); + g.fillRoundedRectangle (trackBounds, trackR); + + g.setColour (offColour.withAlpha (0.4f).interpolatedWith (onColour.withAlpha (0.5f), glowIntensity)); + g.drawRoundedRectangle (trackBounds, trackR, 0.8f); + } + + if (glowIntensity > 0.01f) + { + for (int i = 0; i < 3; ++i) + { + float t = (float) i / 2.0f; + float r = thumbR * (1.5f - t * 0.5f); + float alpha = (0.05f + t * t * 0.12f) * glowIntensity; + g.setColour (onColour.withAlpha (alpha)); + g.fillEllipse (thumbX - r, thumbY - r, r * 2, r * 2); + } + } + + { + juce::Colour thumbTopOff (0xff555566), thumbBotOff (0xff333344); + juce::Colour thumbTopOn = onColour.brighter (0.3f), thumbBotOn = onColour.darker (0.2f); + juce::ColourGradient thumbGrad ( + thumbTopOff.interpolatedWith (thumbTopOn, glowIntensity), thumbX, thumbY - thumbR, + thumbBotOff.interpolatedWith (thumbBotOn, glowIntensity), thumbX, thumbY + thumbR, false); + g.setGradientFill (thumbGrad); + g.fillEllipse (thumbX - thumbR, thumbY - thumbR, thumbR * 2, thumbR * 2); + + g.setColour (juce::Colour (0xff666677).withAlpha (0.5f).interpolatedWith (onColour.withAlpha (0.6f), glowIntensity)); + g.drawEllipse (thumbX - thumbR, thumbY - thumbR, thumbR * 2, thumbR * 2, 0.8f); + + float hlR = thumbR * 0.4f; + g.setColour (juce::Colours::white.withAlpha (0.1f + 0.15f * glowIntensity)); + g.fillEllipse (thumbX - hlR, thumbY - thumbR * 0.6f - hlR * 0.3f, hlR * 2, hlR * 1.2f); + } +} + +// ============================================================ +// ComboBox style +// ============================================================ + +void InstaShadowLookAndFeel::drawComboBox (juce::Graphics& g, int width, int height, bool /*isButtonDown*/, + int /*buttonX*/, int /*buttonY*/, int /*buttonW*/, int /*buttonH*/, + juce::ComboBox& box) +{ + auto bounds = juce::Rectangle (0, 0, (float) width, (float) height); + + g.setColour (bgMedium); + g.fillRoundedRectangle (bounds, 4.0f); + + g.setColour (bgLight.withAlpha (0.5f)); + g.drawRoundedRectangle (bounds.reduced (0.5f), 4.0f, 1.0f); + + // Arrow + float arrowSize = height * 0.3f; + float arrowX = (float) width - height * 0.6f; + float arrowY = ((float) height - arrowSize) * 0.5f; + + juce::Path arrow; + arrow.addTriangle (arrowX, arrowY, + arrowX + arrowSize, arrowY, + arrowX + arrowSize * 0.5f, arrowY + arrowSize); + g.setColour (textSecondary); + g.fillPath (arrow); +} diff --git a/Source/LookAndFeel.h b/Source/LookAndFeel.h new file mode 100644 index 0000000..1d99ad0 --- /dev/null +++ b/Source/LookAndFeel.h @@ -0,0 +1,54 @@ +#pragma once +#include + +class InstaShadowLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + // Colour palette + static inline const juce::Colour bgDark { 0xff1a1a2e }; + static inline const juce::Colour bgMedium { 0xff16213e }; + static inline const juce::Colour bgLight { 0xff0f3460 }; + static inline const juce::Colour textPrimary { 0xffe0e0e0 }; + static inline const juce::Colour textSecondary { 0xff888899 }; + static inline const juce::Colour accent { 0xff00ff88 }; + + // Knob type property key + static constexpr const char* knobTypeProperty = "knobType"; + + InstaShadowLookAndFeel(); + + void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, + float sliderPosProportional, float rotaryStartAngle, + float rotaryEndAngle, juce::Slider& slider) override; + + void drawButtonBackground (juce::Graphics& g, juce::Button& button, + const juce::Colour& backgroundColour, + bool shouldDrawButtonAsHighlighted, + bool shouldDrawButtonAsDown) override; + + void drawToggleButton (juce::Graphics& g, juce::ToggleButton& button, + bool shouldDrawButtonAsHighlighted, + bool shouldDrawButtonAsDown) override; + + void drawComboBox (juce::Graphics& g, int width, int height, bool isButtonDown, + int buttonX, int buttonY, int buttonW, int buttonH, + juce::ComboBox& box) override; + + // Custom fonts + juce::Font getRegularFont (float height) const; + juce::Font getMediumFont (float height) const; + juce::Font getBoldFont (float height) const; + + // Background texture + void drawBackgroundTexture (juce::Graphics& g, juce::Rectangle area); + + juce::Typeface::Ptr getTypefaceForFont (const juce::Font& font) override; + +private: + juce::Typeface::Ptr typefaceRegular; + juce::Typeface::Ptr typefaceMedium; + juce::Typeface::Ptr typefaceBold; + + juce::Image noiseTexture; + void generateNoiseTexture(); +}; diff --git a/Source/NeedleVuMeter.h b/Source/NeedleVuMeter.h new file mode 100644 index 0000000..527547a --- /dev/null +++ b/Source/NeedleVuMeter.h @@ -0,0 +1,146 @@ +#pragma once +#include + +// ============================================================ +// Analog-style needle VU meter (semicircular, like Shadow Hills) +// ============================================================ +class NeedleVuMeter : public juce::Component +{ +public: + void setLevel (float linearLevel) + { + // Convert to dB, map to needle position + float db = (linearLevel > 0.0001f) + ? 20.0f * std::log10 (linearLevel) + : -60.0f; + + // VU range: -20 to +3 dB → 0.0 to 1.0 + float target = juce::jlimit (0.0f, 1.0f, (db + 20.0f) / 23.0f); + + // Smooth needle movement (ballistic) + if (target > needlePos) + needlePos += (target - needlePos) * 0.07f; // slow attack (inertia) + else + needlePos += (target - needlePos) * 0.05f; // moderate release + + repaint(); + } + + void setLabel (const juce::String& text) { label = text; } + + void paint (juce::Graphics& g) override + { + auto bounds = getLocalBounds().toFloat().reduced (2); + float w = bounds.getWidth(); + float h = bounds.getHeight(); + + // Meter face background (warm cream) + float arcH = h * 0.85f; + auto faceRect = bounds.withHeight (arcH); + + g.setColour (juce::Colour (0xff1a1a22)); + g.fillRoundedRectangle (bounds, 4.0f); + + // Cream arc area + auto arcArea = faceRect.reduced (6, 4); + { + juce::ColourGradient grad (juce::Colour (0xfff0e8d0), arcArea.getCentreX(), arcArea.getY(), + juce::Colour (0xffd8d0b8), arcArea.getCentreX(), arcArea.getBottom(), false); + g.setGradientFill (grad); + g.fillRoundedRectangle (arcArea, 3.0f); + } + + // Arc center point (bottom center of arc area) + float cx = arcArea.getCentreX(); + float cy = arcArea.getBottom() - 4.0f; + float radius = std::min (arcArea.getWidth() * 0.45f, arcArea.getHeight() * 0.8f); + + // Scale markings + float startAngle = juce::MathConstants::pi * 1.25f; // -225 deg + float endAngle = juce::MathConstants::pi * 1.75f; // -315 deg (sweep right) + + // Draw scale ticks and labels + g.setFont (std::max (6.0f, h * 0.045f)); + const float dbValues[] = { -20, -10, -7, -5, -3, -1, 0, 1, 2, 3 }; + const int numTicks = 10; + + for (int i = 0; i < numTicks; ++i) + { + float norm = (dbValues[i] + 20.0f) / 23.0f; + float angle = startAngle + norm * (endAngle - startAngle); + + float cosA = std::cos (angle); + float sinA = std::sin (angle); + + float innerR = radius * 0.82f; + float outerR = radius * 0.95f; + bool isMajor = (dbValues[i] == -20 || dbValues[i] == -10 || dbValues[i] == -5 + || dbValues[i] == 0 || dbValues[i] == 3); + + // Tick line + g.setColour (dbValues[i] >= 0 ? juce::Colour (0xffcc3333) : juce::Colour (0xff333333)); + float tickInner = isMajor ? innerR * 0.9f : innerR; + g.drawLine (cx + cosA * tickInner, cy + sinA * tickInner, + cx + cosA * outerR, cy + sinA * outerR, + isMajor ? 1.5f : 0.8f); + + // Label for major ticks + if (isMajor) + { + float labelR = radius * 0.7f; + float lx = cx + cosA * labelR; + float ly = cy + sinA * labelR; + juce::String txt = (dbValues[i] > 0 ? "+" : "") + juce::String ((int) dbValues[i]); + g.setColour (dbValues[i] >= 0 ? juce::Colour (0xffcc3333) : juce::Colour (0xff444444)); + g.drawText (txt, (int) (lx - 12), (int) (ly - 6), 24, 12, juce::Justification::centred); + } + } + + // Red zone arc (0 to +3 dB) + { + float redStart = startAngle + (20.0f / 23.0f) * (endAngle - startAngle); + juce::Path redArc; + redArc.addCentredArc (cx, cy, radius * 0.92f, radius * 0.92f, 0, + redStart, endAngle, true); + g.setColour (juce::Colour (0x33ff3333)); + g.strokePath (redArc, juce::PathStrokeType (radius * 0.08f)); + } + + // Needle + { + float angle = startAngle + needlePos * (endAngle - startAngle); + float cosA = std::cos (angle); + float sinA = std::sin (angle); + + // Needle shadow + g.setColour (juce::Colours::black.withAlpha (0.3f)); + g.drawLine (cx + 1, cy + 1, + cx + cosA * radius * 0.88f + 1, cy + sinA * radius * 0.88f + 1, + 2.0f); + + // Needle + g.setColour (juce::Colour (0xff222222)); + g.drawLine (cx, cy, + cx + cosA * radius * 0.88f, cy + sinA * radius * 0.88f, + 1.5f); + + // Needle pivot dot + g.setColour (juce::Colour (0xff333333)); + g.fillEllipse (cx - 3, cy - 3, 6, 6); + } + + // Label below + g.setColour (juce::Colour (0xffaaaaaa)); + g.setFont (std::max (7.0f, h * 0.05f)); + g.drawText (label, bounds.getX(), bounds.getBottom() - h * 0.18f, + bounds.getWidth(), h * 0.15f, juce::Justification::centred); + + // Border + g.setColour (juce::Colour (0xff333344)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); + } + +private: + float needlePos = 0.0f; // 0..1 mapped to -20..+3 dB + juce::String label; +}; diff --git a/Source/OpticalCell.cpp b/Source/OpticalCell.cpp new file mode 100644 index 0000000..279a080 --- /dev/null +++ b/Source/OpticalCell.cpp @@ -0,0 +1,96 @@ +#include "OpticalCell.h" + +OpticalCell::OpticalCell() {} + +void OpticalCell::prepare (double sampleRate) +{ + sr = sampleRate; + dt = 1.0 / (2.0 * sampleRate); // 2x oversampled + reset(); +} + +void OpticalCell::reset() +{ + q_el = 0.0; + R_cds = R_cds_dark; + lightOutput = 0.0; + lightHistory = 0.0; + currentGain = 1.0; + prevInput = 0.0f; +} + +void OpticalCell::solveImplicitStep (double inputLevel) +{ + // Drive from sidechain level (rectified, normalized 0..1+) + double i_drive = std::max (0.0, inputLevel); + + // Store old state for trapezoidal rule + double q_old = q_el; + double f_old = (i_drive - q_old / C_el) / R_el; + + // Initial guess (forward Euler) + double q_new = q_old + dt * f_old; + + // Newton-Raphson iteration (implicit trapezoidal) + for (int iter = 0; iter < maxNewtonIter; ++iter) + { + double f_new = (i_drive - q_new / C_el) / R_el; + double residual = q_new - q_old - (dt / 2.0) * (f_old + f_new); + double jacobian = 1.0 + dt / (2.0 * R_el * C_el); + + double delta = residual / jacobian; + q_new -= delta; + + if (std::abs (delta) < newtonTol) + break; + } + + q_el = q_new; + + // Light output proportional to STORED ENERGY (voltage^2) + // This ensures continuous light output at steady state + double voltage = q_el / C_el; + lightOutput = eta_el * voltage * voltage; + + // Update memory (illumination history) — explicit Euler + lightHistory += dt * (lightOutput - lightHistory) / memoryTau; + lightHistory = std::max (0.0, lightHistory); + + // CdS resistance: R = k * (L_eff)^(-gamma) + // Memory effect: effective light includes accumulated history + double L_eff = lightOutput + memoryAlpha * lightHistory; + double epsilon = 1.0e-10; + + R_cds = k_cds * std::pow (L_eff + epsilon, -gamma); + R_cds = std::clamp (R_cds, R_cds_min, R_cds_dark); +} + +void OpticalCell::updateGain() +{ + // Shunt attenuator: high R_cds = pass, low R_cds = attenuate + currentGain = R_cds / (R_fixed + R_cds); +} + +float OpticalCell::processSample (float sidechainLevel, float thresholdLinear) +{ + // Envelope above threshold + float envelope = std::max (0.0f, sidechainLevel - thresholdLinear); + + // 2x oversampling: interpolate and process two sub-samples + float mid = (prevInput + envelope) * 0.5f; + prevInput = envelope; + + solveImplicitStep ((double) mid); + solveImplicitStep ((double) envelope); + + updateGain(); + + return (float) currentGain; +} + +float OpticalCell::getGainReductionDb() const +{ + if (currentGain <= 0.0) + return -60.0f; + return (float) (20.0 * std::log10 (currentGain)); +} diff --git a/Source/OpticalCell.h b/Source/OpticalCell.h new file mode 100644 index 0000000..a34908f --- /dev/null +++ b/Source/OpticalCell.h @@ -0,0 +1,71 @@ +#pragma once +#include +#include + +// ============================================================ +// Port-Hamiltonian T4B Electro-Optical Compressor Model +// +// Models the EL panel (capacitive energy store) coupled to a +// CdS photoresistor (nonlinear dissipation) using implicit +// trapezoidal integration with Newton-Raphson iteration. +// +// All parameters are normalized for audio-level (0..1) operation. +// ============================================================ +class OpticalCell +{ +public: + OpticalCell(); + + void prepare (double sampleRate); + void reset(); + + // Process one sample: sidechain input (linear), returns gain (0..1) + float processSample (float sidechainLevel, float thresholdLinear); + + // Current GR in dB (for metering, always <= 0) + float getGainReductionDb() const; + +private: + double sr = 44100.0; + double dt = 1.0 / 88200.0; // 2x oversampled time step + + // ---- Port-Hamiltonian State (normalized units) ---- + + // EL panel: capacitive energy store + double q_el = 0.0; // charge state + double C_el = 0.01; // capacitance (~10ms attack with R_el=1) + double R_el = 1.0; // series resistance (normalized) + double eta_el = 50.0; // electro-optical efficiency (scaled for audio levels) + + // CdS photoresistor: nonlinear dissipation + double R_cds = 100.0; // current resistance (normalized) + double R_cds_dark = 100.0; // dark resistance (no light → no compression) + double R_cds_min = 0.01; // minimum resistance (max compression) + double k_cds = 1.0; // CdS scaling factor + double gamma = 0.7; // CdS nonlinearity exponent + + // Memory effect (CdS illumination history) + double lightHistory = 0.0; + double memoryTau = 2.0; // memory time constant (seconds) + double memoryAlpha = 0.3; // history influence factor + + // Light state + double lightOutput = 0.0; + + // Signal path impedance for gain (normalized) + double R_fixed = 1.0; + + // Current gain (linear) + double currentGain = 1.0; + + // Implicit solver + static constexpr int maxNewtonIter = 5; + static constexpr double newtonTol = 1.0e-8; + + // 2x oversampling + float prevInput = 0.0f; + + // Core methods + void solveImplicitStep (double inputLevel); + void updateGain(); +}; diff --git a/Source/OpticalPanel.cpp b/Source/OpticalPanel.cpp new file mode 100644 index 0000000..080030e --- /dev/null +++ b/Source/OpticalPanel.cpp @@ -0,0 +1,73 @@ +#include "OpticalPanel.h" + +OpticalPanel::OpticalPanel() +{ + titleLabel.setText ("OPTICAL", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centred); + titleLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textPrimary); + addAndMakeVisible (titleLabel); + + auto setupKnob = [this] (juce::Slider& knob, juce::Label& label, const juce::String& name, + double min, double max, double def, const juce::String& type, + const juce::String& suffix = "") + { + knob.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + knob.setTextBoxStyle (juce::Slider::TextBoxBelow, false, 60, 14); + knob.setRange (min, max); + knob.setValue (def); + knob.setTextValueSuffix (suffix); + knob.getProperties().set (InstaShadowLookAndFeel::knobTypeProperty, type); + addAndMakeVisible (knob); + + label.setText (name, juce::dontSendNotification); + label.setJustificationType (juce::Justification::centred); + label.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textSecondary); + addAndMakeVisible (label); + }; + + setupKnob (thresholdKnob, threshLabel, "Threshold", -40.0, 0.0, -20.0, "orange", " dB"); + setupKnob (gainKnob, gainLabel, "Gain", 0.0, 20.0, 0.0, "orange", " dB"); + setupKnob (scHpfKnob, hpfLabel, "SC HPF", 20.0, 500.0, 90.0, "dark", " Hz"); + scHpfKnob.setSkewFactorFromMidPoint (100.0); + + bypassToggle.setButtonText (""); + addAndMakeVisible (bypassToggle); + bypassLabel.setText ("Bypass", juce::dontSendNotification); + bypassLabel.setJustificationType (juce::Justification::centred); + bypassLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textSecondary); + addAndMakeVisible (bypassLabel); +} + +void OpticalPanel::resized() +{ + auto bounds = getLocalBounds().reduced (4); + titleLabel.setBounds (bounds.removeFromTop (18)); + + int availH = bounds.getHeight() - 22; + int knobH = availH / 3; + + auto k1 = bounds.removeFromTop (knobH); + thresholdKnob.setBounds (k1.withTrimmedBottom (14).reduced (4, 0)); + threshLabel.setBounds (k1.getX(), k1.getBottom() - 14, k1.getWidth(), 14); + + auto k2 = bounds.removeFromTop (knobH); + gainKnob.setBounds (k2.withTrimmedBottom (14).reduced (4, 0)); + gainLabel.setBounds (k2.getX(), k2.getBottom() - 14, k2.getWidth(), 14); + + auto k3 = bounds.removeFromTop (knobH); + scHpfKnob.setBounds (k3.withTrimmedBottom (14).reduced (4, 0)); + hpfLabel.setBounds (k3.getX(), k3.getBottom() - 14, k3.getWidth(), 14); + + auto bpRow = bounds; + bypassLabel.setBounds (bpRow.removeFromLeft (bpRow.getWidth() / 2)); + bypassToggle.setBounds (bpRow.reduced (4, 2)); +} + +void OpticalPanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaShadowLookAndFeel::bgMedium.withAlpha (0.5f)); + g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (InstaShadowLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} diff --git a/Source/OpticalPanel.h b/Source/OpticalPanel.h new file mode 100644 index 0000000..066a4cc --- /dev/null +++ b/Source/OpticalPanel.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include "LookAndFeel.h" + +class OpticalPanel : public juce::Component +{ +public: + OpticalPanel(); + void resized() override; + void paint (juce::Graphics& g) override; + + juce::Slider thresholdKnob; + juce::Slider gainKnob; + juce::Slider scHpfKnob; + juce::ToggleButton bypassToggle; + +private: + juce::Label titleLabel; + juce::Label threshLabel, gainLabel, hpfLabel, bypassLabel; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (OpticalPanel) +}; diff --git a/Source/OutputPanel.cpp b/Source/OutputPanel.cpp new file mode 100644 index 0000000..2750cf7 --- /dev/null +++ b/Source/OutputPanel.cpp @@ -0,0 +1,52 @@ +#include "OutputPanel.h" + +OutputPanel::OutputPanel() +{ + titleLabel.setText ("OUTPUT", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centredLeft); + titleLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textPrimary); + addAndMakeVisible (titleLabel); + + outputGainKnob.setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag); + outputGainKnob.setTextBoxStyle (juce::Slider::TextBoxBelow, false, 60, 14); + outputGainKnob.setRange (-12.0, 12.0); + outputGainKnob.setValue (0.0); + outputGainKnob.setTextValueSuffix (" dB"); + outputGainKnob.getProperties().set (InstaShadowLookAndFeel::knobTypeProperty, "orange"); + addAndMakeVisible (outputGainKnob); + + gainLabel.setText ("Gain", juce::dontSendNotification); + gainLabel.setJustificationType (juce::Justification::centred); + gainLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textSecondary); + addAndMakeVisible (gainLabel); + + addAndMakeVisible (vuMeter); +} + +void OutputPanel::resized() +{ + auto bounds = getLocalBounds().reduced (4); + titleLabel.setBounds (bounds.removeFromTop (20)); + + int halfW = bounds.getWidth() / 2; + + auto knobArea = bounds.removeFromLeft (halfW); + auto knobRect = knobArea.withTrimmedBottom (16); + outputGainKnob.setBounds (knobRect.reduced (2)); + gainLabel.setBounds (knobArea.getX(), knobRect.getBottom() - 2, knobArea.getWidth(), 16); + + // Small VU meter, limited height + auto vuArea = bounds.reduced (8, 4); + int maxVuH = std::min (vuArea.getHeight(), (int) (knobRect.getHeight() * 0.6f)); + int maxVuW = std::min (vuArea.getWidth(), 40); + vuMeter.setBounds (vuArea.withSizeKeepingCentre (maxVuW, maxVuH)); +} + +void OutputPanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaShadowLookAndFeel::bgMedium.withAlpha (0.5f)); + g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (InstaShadowLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} diff --git a/Source/OutputPanel.h b/Source/OutputPanel.h new file mode 100644 index 0000000..05d00db --- /dev/null +++ b/Source/OutputPanel.h @@ -0,0 +1,21 @@ +#pragma once +#include +#include "LookAndFeel.h" +#include "VuMeter.h" + +class OutputPanel : public juce::Component +{ +public: + OutputPanel(); + void resized() override; + void paint (juce::Graphics& g) override; + + juce::Slider outputGainKnob; + VuMeter vuMeter; + +private: + juce::Label titleLabel; + juce::Label gainLabel; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (OutputPanel) +}; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp new file mode 100644 index 0000000..f550cd7 --- /dev/null +++ b/Source/PluginEditor.cpp @@ -0,0 +1,210 @@ +#include "PluginEditor.h" + +InstaShadowEditor::InstaShadowEditor (InstaShadowProcessor& p) + : AudioProcessorEditor (&p), processor (p) +{ + setLookAndFeel (&lookAndFeel); + setSize (1000, 600); + setResizable (true, true); + setResizeLimits (800, 500, 1400, 900); + + // Title + titleLabel.setText ("INSTASHADOW", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centredLeft); + titleLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::accent); + addAndMakeVisible (titleLabel); + + versionLabel.setText (kInstaShadowVersion, juce::dontSendNotification); + versionLabel.setJustificationType (juce::Justification::centredLeft); + versionLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textSecondary); + addAndMakeVisible (versionLabel); + + // Link + linkToggle.setToggleState (true, juce::dontSendNotification); + addAndMakeVisible (linkToggle); + linkLabel.setText ("LINK", juce::dontSendNotification); + linkLabel.setJustificationType (juce::Justification::centredRight); + linkLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textSecondary); + addAndMakeVisible (linkLabel); + + // Bypass + bypassToggle.setToggleState (false, juce::dontSendNotification); + addAndMakeVisible (bypassToggle); + bypassLabel.setText ("BYPASS", juce::dontSendNotification); + bypassLabel.setJustificationType (juce::Justification::centredRight); + bypassLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textSecondary); + addAndMakeVisible (bypassLabel); + + // Panels + addAndMakeVisible (opticalPanel); + addAndMakeVisible (discretePanel); + addAndMakeVisible (transformerPanel); + addAndMakeVisible (outputPanel); + + // Needle VU meters + vuMeterL.setLabel ("L"); + addAndMakeVisible (vuMeterL); + vuMeterR.setLabel ("R"); + addAndMakeVisible (vuMeterR); + + // GR meters (compact bars) + optoGrMeter.setLabel ("OPTICAL GR"); + optoGrMeter.setBarColour (juce::Colour (0xffff8833)); + addAndMakeVisible (optoGrMeter); + + vcaGrMeter.setLabel ("DISCRETE GR"); + vcaGrMeter.setBarColour (juce::Colour (0xff4488ff)); + addAndMakeVisible (vcaGrMeter); + + syncKnobsToEngine(); + startTimerHz (30); +} + +InstaShadowEditor::~InstaShadowEditor() +{ + setLookAndFeel (nullptr); + stopTimer(); +} + +void InstaShadowEditor::syncKnobsToEngine() +{ + auto& eng = processor.getEngine(); + + opticalPanel.thresholdKnob.setValue (eng.optoThresholdDb.load(), juce::dontSendNotification); + opticalPanel.gainKnob.setValue (eng.optoGainDb.load(), juce::dontSendNotification); + opticalPanel.scHpfKnob.setValue (eng.optoScHpfHz.load(), juce::dontSendNotification); + opticalPanel.bypassToggle.setToggleState (eng.optoBypass.load(), juce::dontSendNotification); + + discretePanel.thresholdKnob.setValue (eng.vcaThresholdDb.load(), juce::dontSendNotification); + discretePanel.gainKnob.setValue (eng.vcaGainDb.load(), juce::dontSendNotification); + discretePanel.ratioBox.setSelectedId (eng.vcaRatioIndex.load() + 1, juce::dontSendNotification); + discretePanel.attackBox.setSelectedId (eng.vcaAttackIndex.load() + 1, juce::dontSendNotification); + discretePanel.releaseBox.setSelectedId (eng.vcaReleaseIndex.load() + 1, juce::dontSendNotification); + discretePanel.bypassToggle.setToggleState (eng.vcaBypass.load(), juce::dontSendNotification); + + transformerPanel.setSelectedType (eng.transformerType.load()); + outputPanel.outputGainKnob.setValue (eng.outputGainDb.load(), juce::dontSendNotification); + linkToggle.setToggleState (eng.stereoLink.load(), juce::dontSendNotification); + bypassToggle.setToggleState (eng.globalBypass.load(), juce::dontSendNotification); +} + +void InstaShadowEditor::syncEngineFromKnobs() +{ + auto& eng = processor.getEngine(); + + eng.optoThresholdDb.store ((float) opticalPanel.thresholdKnob.getValue()); + eng.optoGainDb.store ((float) opticalPanel.gainKnob.getValue()); + eng.optoScHpfHz.store ((float) opticalPanel.scHpfKnob.getValue()); + eng.optoBypass.store (opticalPanel.bypassToggle.getToggleState()); + + eng.vcaThresholdDb.store ((float) discretePanel.thresholdKnob.getValue()); + eng.vcaGainDb.store ((float) discretePanel.gainKnob.getValue()); + eng.vcaRatioIndex.store (discretePanel.ratioBox.getSelectedId() - 1); + eng.vcaAttackIndex.store (discretePanel.attackBox.getSelectedId() - 1); + eng.vcaReleaseIndex.store (discretePanel.releaseBox.getSelectedId() - 1); + eng.vcaBypass.store (discretePanel.bypassToggle.getToggleState()); + + eng.transformerType.store (transformerPanel.getSelectedType()); + eng.outputGainDb.store ((float) outputPanel.outputGainKnob.getValue()); + eng.stereoLink.store (linkToggle.getToggleState()); + eng.globalBypass.store (bypassToggle.getToggleState()); +} + +void InstaShadowEditor::timerCallback() +{ + syncEngineFromKnobs(); + + auto& eng = processor.getEngine(); + + // Needle VU meters + vuMeterL.setLevel (eng.outputLevelL.load()); + vuMeterR.setLevel (eng.outputLevelR.load()); + + // GR meters + optoGrMeter.setGainReduction (eng.optoGrDb.load()); + vcaGrMeter.setGainReduction (eng.vcaGrDb.load()); + + // Output panel VU + outputPanel.vuMeter.setLevel (eng.outputLevelL.load(), eng.outputLevelR.load()); +} + +void InstaShadowEditor::paint (juce::Graphics& g) +{ + g.fillAll (InstaShadowLookAndFeel::bgDark); + lookAndFeel.drawBackgroundTexture (g, getLocalBounds()); + + float scale = (float) getHeight() / 600.0f; + int topBarH = (int) (36.0f * scale); + + g.setColour (InstaShadowLookAndFeel::bgMedium.withAlpha (0.7f)); + g.fillRect (0, 0, getWidth(), topBarH); + g.setColour (InstaShadowLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawHorizontalLine (topBarH, 0, (float) getWidth()); +} + +void InstaShadowEditor::resized() +{ + auto bounds = getLocalBounds(); + float scale = (float) getHeight() / 600.0f; + + int topBarH = (int) (36.0f * scale); + int pad = (int) (6.0f * scale); + + // ===== TOP BAR ===== + auto topBar = bounds.removeFromTop (topBarH).reduced (pad, 0); + titleLabel.setFont (lookAndFeel.getBoldFont (20.0f * scale)); + titleLabel.setBounds (topBar.removeFromLeft ((int) (140 * scale))); + versionLabel.setFont (lookAndFeel.getRegularFont (13.0f * scale)); + versionLabel.setBounds (topBar.removeFromLeft ((int) (50 * scale))); + + auto bypassArea = topBar.removeFromRight ((int) (30 * scale)); + bypassToggle.setBounds (bypassArea); + bypassLabel.setFont (lookAndFeel.getRegularFont (11.0f * scale)); + bypassLabel.setBounds (topBar.removeFromRight ((int) (55 * scale))); + topBar.removeFromRight (pad); + auto linkArea = topBar.removeFromRight ((int) (30 * scale)); + linkToggle.setBounds (linkArea); + linkLabel.setFont (lookAndFeel.getRegularFont (11.0f * scale)); + linkLabel.setBounds (topBar.removeFromRight ((int) (40 * scale))); + + auto content = bounds.reduced (pad); + + // ===== MAIN: 3 columns ===== + int sideW = (int) (content.getWidth() * 0.22f); + auto mainRow = content; + + // Left: Optical + opticalPanel.setBounds (mainRow.removeFromLeft (sideW)); + mainRow.removeFromLeft (pad); + + // Right: Discrete + auto rightCol = mainRow.removeFromRight (sideW); + discretePanel.setBounds (rightCol); + mainRow.removeFromRight (pad); + + // Center column: VU meters, GR bars, Transformer, Output — all stacked + auto centerArea = mainRow; + + // Two needle VU meters side by side (~30%) + int vuH = (int) (centerArea.getHeight() * 0.30f); + auto vuRow = centerArea.removeFromTop (vuH); + int vuW = (vuRow.getWidth() - pad) / 2; + vuMeterL.setBounds (vuRow.removeFromLeft (vuW)); + vuRow.removeFromLeft (pad); + vuMeterR.setBounds (vuRow); + centerArea.removeFromTop (pad); + + // Two GR meter bars (~15%) + int grBarH = (int) (centerArea.getHeight() * 0.12f); + optoGrMeter.setBounds (centerArea.removeFromTop (grBarH)); + centerArea.removeFromTop (pad); + vcaGrMeter.setBounds (centerArea.removeFromTop (grBarH)); + centerArea.removeFromTop (pad); + + // Transformer + Output side by side in remaining center space + auto botCenter = centerArea; + int botHalf = botCenter.getWidth() / 2; + transformerPanel.setBounds (botCenter.removeFromLeft (botHalf - pad / 2)); + botCenter.removeFromLeft (pad); + outputPanel.setBounds (botCenter); +} diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h new file mode 100644 index 0000000..44441e3 --- /dev/null +++ b/Source/PluginEditor.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include "PluginProcessor.h" +#include "LookAndFeel.h" +#include "OpticalPanel.h" +#include "DiscretePanel.h" +#include "TransformerPanel.h" +#include "OutputPanel.h" +#include "GRMeter.h" +#include "NeedleVuMeter.h" + +static constexpr const char* kInstaShadowVersion = "v1.0"; + +class InstaShadowEditor : public juce::AudioProcessorEditor, + public juce::Timer +{ +public: + explicit InstaShadowEditor (InstaShadowProcessor&); + ~InstaShadowEditor() override; + + void paint (juce::Graphics&) override; + void resized() override; + void timerCallback() override; + +private: + InstaShadowProcessor& processor; + InstaShadowLookAndFeel lookAndFeel; + + // Top bar + juce::Label titleLabel; + juce::Label versionLabel; + juce::ToggleButton linkToggle; + juce::Label linkLabel; + juce::ToggleButton bypassToggle; + juce::Label bypassLabel; + + // Side panels + OpticalPanel opticalPanel; + DiscretePanel discretePanel; + + // Center: needle VU meters + GR bars + NeedleVuMeter vuMeterL; + NeedleVuMeter vuMeterR; + GRMeter optoGrMeter; + GRMeter vcaGrMeter; + + // Bottom panels + TransformerPanel transformerPanel; + OutputPanel outputPanel; + + void syncKnobsToEngine(); + void syncEngineFromKnobs(); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (InstaShadowEditor) +}; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp new file mode 100644 index 0000000..7d88933 --- /dev/null +++ b/Source/PluginProcessor.cpp @@ -0,0 +1,87 @@ +#include "PluginProcessor.h" +#include "PluginEditor.h" + +InstaShadowProcessor::InstaShadowProcessor() + : AudioProcessor (BusesProperties() + .withInput ("Input", juce::AudioChannelSet::stereo(), true) + .withOutput ("Output", juce::AudioChannelSet::stereo(), true)) +{ +} + +InstaShadowProcessor::~InstaShadowProcessor() {} + +void InstaShadowProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) +{ + engine.prepare (sampleRate, samplesPerBlock); +} + +void InstaShadowProcessor::releaseResources() {} + +bool InstaShadowProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const +{ + if (layouts.getMainOutputChannelSet() != juce::AudioChannelSet::stereo()) + return false; + if (layouts.getMainInputChannelSet() != juce::AudioChannelSet::stereo()) + return false; + return true; +} + +void InstaShadowProcessor::processBlock (juce::AudioBuffer& buffer, juce::MidiBuffer&) +{ + juce::ScopedNoDenormals noDenormals; + engine.processBlock (buffer); +} + +juce::AudioProcessorEditor* InstaShadowProcessor::createEditor() +{ + return new InstaShadowEditor (*this); +} + +void InstaShadowProcessor::getStateInformation (juce::MemoryBlock& destData) +{ + auto xml = std::make_unique ("InstaShadowState"); + + xml->setAttribute ("optoThreshold", (double) engine.optoThresholdDb.load()); + xml->setAttribute ("optoGain", (double) engine.optoGainDb.load()); + xml->setAttribute ("optoScHpf", (double) engine.optoScHpfHz.load()); + xml->setAttribute ("optoBypass", engine.optoBypass.load()); + xml->setAttribute ("vcaThreshold", (double) engine.vcaThresholdDb.load()); + xml->setAttribute ("vcaGain", (double) engine.vcaGainDb.load()); + xml->setAttribute ("vcaRatio", engine.vcaRatioIndex.load()); + xml->setAttribute ("vcaAttack", engine.vcaAttackIndex.load()); + xml->setAttribute ("vcaRelease", engine.vcaReleaseIndex.load()); + xml->setAttribute ("vcaBypass", engine.vcaBypass.load()); + xml->setAttribute ("transformerType", engine.transformerType.load()); + xml->setAttribute ("outputGain", (double) engine.outputGainDb.load()); + xml->setAttribute ("stereoLink", engine.stereoLink.load()); + xml->setAttribute ("globalBypass", engine.globalBypass.load()); + + copyXmlToBinary (*xml, destData); +} + +void InstaShadowProcessor::setStateInformation (const void* data, int sizeInBytes) +{ + auto xml = getXmlFromBinary (data, sizeInBytes); + if (xml == nullptr || ! xml->hasTagName ("InstaShadowState")) + return; + + engine.optoThresholdDb.store ((float) xml->getDoubleAttribute ("optoThreshold", -20.0)); + engine.optoGainDb.store ((float) xml->getDoubleAttribute ("optoGain", 0.0)); + engine.optoScHpfHz.store ((float) xml->getDoubleAttribute ("optoScHpf", 90.0)); + engine.optoBypass.store (xml->getBoolAttribute ("optoBypass", false)); + engine.vcaThresholdDb.store ((float) xml->getDoubleAttribute ("vcaThreshold", -20.0)); + engine.vcaGainDb.store ((float) xml->getDoubleAttribute ("vcaGain", 0.0)); + engine.vcaRatioIndex.store (xml->getIntAttribute ("vcaRatio", 1)); + engine.vcaAttackIndex.store (xml->getIntAttribute ("vcaAttack", 2)); + engine.vcaReleaseIndex.store (xml->getIntAttribute ("vcaRelease", 2)); + engine.vcaBypass.store (xml->getBoolAttribute ("vcaBypass", false)); + engine.transformerType.store (xml->getIntAttribute ("transformerType", 0)); + engine.outputGainDb.store ((float) xml->getDoubleAttribute ("outputGain", 0.0)); + engine.stereoLink.store (xml->getBoolAttribute ("stereoLink", true)); + engine.globalBypass.store (xml->getBoolAttribute ("globalBypass", false)); +} + +juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() +{ + return new InstaShadowProcessor(); +} diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h new file mode 100644 index 0000000..2bf2343 --- /dev/null +++ b/Source/PluginProcessor.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include "CompressorEngine.h" + +class InstaShadowProcessor : public juce::AudioProcessor +{ +public: + InstaShadowProcessor(); + ~InstaShadowProcessor() override; + + void prepareToPlay (double sampleRate, int samplesPerBlock) override; + void releaseResources() override; + + bool isBusesLayoutSupported (const BusesLayout& layouts) const override; + void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; + + juce::AudioProcessorEditor* createEditor() override; + bool hasEditor() const override { return true; } + + const juce::String getName() const override { return JucePlugin_Name; } + bool acceptsMidi() const override { return false; } + bool producesMidi() const override { return false; } + bool isMidiEffect() const override { return false; } + double getTailLengthSeconds() const override { return 0.0; } + + int getNumPrograms() override { return 1; } + int getCurrentProgram() override { return 0; } + void setCurrentProgram (int) override {} + const juce::String getProgramName (int) override { return {}; } + void changeProgramName (int, const juce::String&) override {} + + void getStateInformation (juce::MemoryBlock& destData) override; + void setStateInformation (const void* data, int sizeInBytes) override; + + CompressorEngine& getEngine() { return engine; } + +private: + CompressorEngine engine; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (InstaShadowProcessor) +}; diff --git a/Source/TransformerPanel.cpp b/Source/TransformerPanel.cpp new file mode 100644 index 0000000..6d20629 --- /dev/null +++ b/Source/TransformerPanel.cpp @@ -0,0 +1,84 @@ +#include "TransformerPanel.h" + +TransformerPanel::TransformerPanel() +{ + titleLabel.setText ("TRANSFORMER", juce::dontSendNotification); + titleLabel.setJustificationType (juce::Justification::centredLeft); + titleLabel.setColour (juce::Label::textColourId, InstaShadowLookAndFeel::textPrimary); + addAndMakeVisible (titleLabel); + + auto setupButton = [this] (juce::TextButton& btn, int typeId) + { + btn.setClickingTogglesState (false); + btn.onClick = [this, typeId, &btn] + { + if (currentType == typeId) + currentType = 0; // deselect → Off + else + currentType = typeId; + updateButtonStates(); + }; + addAndMakeVisible (btn); + }; + + setupButton (nickelButton, 1); + setupButton (ironButton, 2); + setupButton (steelButton, 3); + + updateButtonStates(); +} + +void TransformerPanel::setSelectedType (int type) +{ + currentType = std::clamp (type, 0, 3); + updateButtonStates(); +} + +void TransformerPanel::updateButtonStates() +{ + auto setColour = [this] (juce::TextButton& btn, bool active) + { + if (active) + { + btn.setColour (juce::TextButton::buttonColourId, InstaShadowLookAndFeel::accent.withAlpha (0.4f)); + btn.setColour (juce::TextButton::textColourOffId, InstaShadowLookAndFeel::accent); + } + else + { + btn.setColour (juce::TextButton::buttonColourId, InstaShadowLookAndFeel::bgMedium); + btn.setColour (juce::TextButton::textColourOffId, InstaShadowLookAndFeel::textSecondary); + } + }; + + setColour (nickelButton, currentType == 1); + setColour (ironButton, currentType == 2); + setColour (steelButton, currentType == 3); + + repaint(); +} + +void TransformerPanel::resized() +{ + auto bounds = getLocalBounds().reduced (4); + titleLabel.setBounds (bounds.removeFromTop (20)); + + auto btnArea = bounds.reduced (8, 4); + int btnH = std::min ((btnArea.getHeight() - 8) / 3, 28); + int totalH = btnH * 3 + 8; + auto centred = btnArea.withSizeKeepingCentre (std::min (btnArea.getWidth(), 120), totalH); + + nickelButton.setBounds (centred.removeFromTop (btnH).reduced (0, 1)); + centred.removeFromTop (4); + ironButton.setBounds (centred.removeFromTop (btnH).reduced (0, 1)); + centred.removeFromTop (4); + steelButton.setBounds (centred.removeFromTop (btnH).reduced (0, 1)); +} + +void TransformerPanel::paint (juce::Graphics& g) +{ + auto bounds = getLocalBounds().toFloat(); + g.setColour (InstaShadowLookAndFeel::bgMedium.withAlpha (0.5f)); + g.fillRoundedRectangle (bounds, 4.0f); + g.setColour (InstaShadowLookAndFeel::bgLight.withAlpha (0.3f)); + g.drawRoundedRectangle (bounds, 4.0f, 1.0f); +} diff --git a/Source/TransformerPanel.h b/Source/TransformerPanel.h new file mode 100644 index 0000000..8287ecf --- /dev/null +++ b/Source/TransformerPanel.h @@ -0,0 +1,26 @@ +#pragma once +#include +#include "LookAndFeel.h" + +class TransformerPanel : public juce::Component +{ +public: + TransformerPanel(); + void resized() override; + void paint (juce::Graphics& g) override; + + int getSelectedType() const { return currentType; } + void setSelectedType (int type); + + juce::TextButton nickelButton { "NICKEL" }; + juce::TextButton ironButton { "IRON" }; + juce::TextButton steelButton { "STEEL" }; + +private: + juce::Label titleLabel; + int currentType = 0; // 0=Off + + void updateButtonStates(); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (TransformerPanel) +}; diff --git a/Source/TransformerSaturation.cpp b/Source/TransformerSaturation.cpp new file mode 100644 index 0000000..21f2eb5 --- /dev/null +++ b/Source/TransformerSaturation.cpp @@ -0,0 +1,108 @@ +#include "TransformerSaturation.h" + +TransformerSaturation::TransformerSaturation() {} + +void TransformerSaturation::prepare (double sampleRate, int samplesPerBlock) +{ + sr = sampleRate; + oversampler.initProcessing ((size_t) samplesPerBlock); + + // Iron tonestack: very subtle low shelf +0.2dB at 110Hz + auto ironCoeffs = juce::dsp::IIR::Coefficients::makeLowShelf ( + sampleRate, 110.0f, 0.707f, juce::Decibels::decibelsToGain (0.2f)); + ironBoostL.coefficients = ironCoeffs; + ironBoostR.coefficients = ironCoeffs; + + // Steel tonestack: subtle low shelf +0.4dB at 40Hz + auto steelCoeffs = juce::dsp::IIR::Coefficients::makeLowShelf ( + sampleRate, 40.0f, 0.707f, juce::Decibels::decibelsToGain (0.4f)); + steelBoostL.coefficients = steelCoeffs; + steelBoostR.coefficients = steelCoeffs; +} + +void TransformerSaturation::reset() +{ + oversampler.reset(); + ironBoostL.reset(); ironBoostR.reset(); + steelBoostL.reset(); steelBoostR.reset(); +} + +void TransformerSaturation::processBlock (juce::AudioBuffer& buffer, Type type) +{ + if (type == Off) return; + + const int numChannels = std::min (buffer.getNumChannels(), 2); + const int numSamples = buffer.getNumSamples(); + + // Select parameters per type: + // drive: how hard the tanh clips (1.0 = no effect) + // even: 2nd harmonic amount (asymmetric warmth) + // odd: 3rd harmonic amount (edge/presence) + // mix: wet/dry blend (keeps effect very subtle) + float drive, even, odd, mix; + + switch (type) + { + case Nickel: + drive = 1.05f; even = 0.002f; odd = 0.0005f; mix = 0.3f; + break; + case Iron: + drive = 1.15f; even = 0.008f; odd = 0.002f; mix = 0.5f; + break; + case Steel: + drive = 1.3f; even = 0.012f; odd = 0.008f; mix = 0.6f; + break; + default: + return; + } + + // 4x upsample + juce::dsp::AudioBlock block (buffer); + auto oversampledBlock = oversampler.processSamplesUp (block); + + const int osNumSamples = (int) oversampledBlock.getNumSamples(); + const int osNumChannels = std::min ((int) oversampledBlock.getNumChannels(), 2); + + // Apply waveshaping at oversampled rate + for (int ch = 0; ch < osNumChannels; ++ch) + { + float* data = oversampledBlock.getChannelPointer ((size_t) ch); + + for (int i = 0; i < osNumSamples; ++i) + { + float x = data[i]; + float dry = x; + + // Soft-clip: tanh(drive*x)/drive — unity gain at DC + float shaped = std::tanh (drive * x) / drive; + + // Add subtle harmonic content + shaped += even * x * std::abs (x); // 2nd harmonic (even-order) + shaped -= odd * x * x * x; // 3rd harmonic (odd-order) + + // Blend dry/wet to keep the effect subtle + data[i] = dry + (shaped - dry) * mix; + } + } + + // 4x downsample + oversampler.processSamplesDown (block); + + // Post-saturation tonestack (very subtle EQ coloration) + if (type == Iron) + { + for (int i = 0; i < numSamples; ++i) + { + if (numChannels > 0) buffer.setSample (0, i, ironBoostL.processSample (buffer.getSample (0, i))); + if (numChannels > 1) buffer.setSample (1, i, ironBoostR.processSample (buffer.getSample (1, i))); + } + } + else if (type == Steel) + { + for (int i = 0; i < numSamples; ++i) + { + if (numChannels > 0) buffer.setSample (0, i, steelBoostL.processSample (buffer.getSample (0, i))); + if (numChannels > 1) buffer.setSample (1, i, steelBoostR.processSample (buffer.getSample (1, i))); + } + } +} diff --git a/Source/TransformerSaturation.h b/Source/TransformerSaturation.h new file mode 100644 index 0000000..c01f91e --- /dev/null +++ b/Source/TransformerSaturation.h @@ -0,0 +1,34 @@ +#pragma once +#include + +class TransformerSaturation +{ +public: + enum Type { Off = 0, Nickel = 1, Iron = 2, Steel = 3 }; + + TransformerSaturation(); + + void prepare (double sampleRate, int samplesPerBlock); + void reset(); + + // Process buffer in-place with 4x oversampling + void processBlock (juce::AudioBuffer& buffer, Type type); + +private: + double sr = 44100.0; + + // 4x oversampler (stereo) + juce::dsp::Oversampling oversampler { 2, 2, + juce::dsp::Oversampling::filterHalfBandPolyphaseIIR }; + + // Subtle tonestack (post-saturation) + juce::dsp::IIR::Filter ironBoostL, ironBoostR; + juce::dsp::IIR::Filter steelBoostL, steelBoostR; + + // Simple pre-emphasis for frequency-dependent saturation + // Boost lows before waveshaper, compensate after + juce::dsp::IIR::Filter preEmphL, preEmphR; + juce::dsp::IIR::Filter deEmphL, deEmphR; + + void setupPreEmphasis (double sampleRate, float boostDb); +}; diff --git a/Source/VCACompressor.cpp b/Source/VCACompressor.cpp new file mode 100644 index 0000000..aaa2985 --- /dev/null +++ b/Source/VCACompressor.cpp @@ -0,0 +1,100 @@ +#include "VCACompressor.h" + +VCACompressor::VCACompressor() {} + +void VCACompressor::prepare (double sampleRate) +{ + sr = sampleRate; + reset(); +} + +void VCACompressor::reset() +{ + smoothedGrDb = 0.0; + dualFastEnv = 0.0; + dualSlowEnv = 0.0; + wasCompressing = false; + currentGrDb = 0.0f; +} + +double VCACompressor::makeCoeff (double timeSec) const +{ + if (timeSec <= 0.0) return 0.0; + return std::exp (-1.0 / (sr * timeSec)); +} + +float VCACompressor::computeGainReduction (float inputDb, float thresholdDb, float ratio) const +{ + // Soft-knee gain computer + float halfKnee = kneeWidthDb * 0.5f; + + if (inputDb < thresholdDb - halfKnee) + { + return 0.0f; // below threshold — no compression + } + else if (inputDb > thresholdDb + halfKnee) + { + // Above knee — full compression + float compressed = thresholdDb + (inputDb - thresholdDb) / ratio; + return compressed - inputDb; // negative dB value + } + else + { + // In knee region — quadratic interpolation + float x = inputDb - thresholdDb + halfKnee; + return ((1.0f / ratio) - 1.0f) * x * x / (2.0f * kneeWidthDb); + } +} + +float VCACompressor::processSample (float sidechainDb, float thresholdDb, float ratio, + float attackSec, float releaseSec, bool dualRelease) +{ + // Compute desired gain reduction + float desiredGrDb = computeGainReduction (sidechainDb, thresholdDb, ratio); + + if (! dualRelease) + { + // Standard attack/release smoothing + double coeff; + if (desiredGrDb < smoothedGrDb) + coeff = makeCoeff ((double) attackSec); // attacking (GR going more negative) + else + coeff = makeCoeff ((double) releaseSec); // releasing + + smoothedGrDb = coeff * smoothedGrDb + (1.0 - coeff) * (double) desiredGrDb; + } + else + { + // Dual release mode: two-stage release mimicking optical behavior + double attackCoeff = makeCoeff ((double) attackSec); + + if (desiredGrDb < smoothedGrDb) + { + // Attacking + smoothedGrDb = attackCoeff * smoothedGrDb + (1.0 - attackCoeff) * (double) desiredGrDb; + dualFastEnv = smoothedGrDb; + dualSlowEnv = smoothedGrDb; + wasCompressing = true; + } + else + { + // Releasing: blend fast (~60ms) and slow (~2s) release + double fastCoeff = makeCoeff (0.06); // 60ms — first 50-80% of recovery + double slowCoeff = makeCoeff (2.0); // 2s — remaining recovery + + dualFastEnv = fastCoeff * dualFastEnv + (1.0 - fastCoeff) * (double) desiredGrDb; + dualSlowEnv = slowCoeff * dualSlowEnv + (1.0 - slowCoeff) * (double) desiredGrDb; + + // Blend: use the DEEPER (more negative) of the two + // This naturally creates the two-stage behavior: + // fast env recovers quickly, slow env holds longer + smoothedGrDb = std::min (dualFastEnv, dualSlowEnv); + wasCompressing = false; + } + } + + currentGrDb = (float) smoothedGrDb; + + // Convert GR dB to linear gain + return std::pow (10.0f, currentGrDb / 20.0f); +} diff --git a/Source/VCACompressor.h b/Source/VCACompressor.h new file mode 100644 index 0000000..f93aa7f --- /dev/null +++ b/Source/VCACompressor.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include + +class VCACompressor +{ +public: + VCACompressor(); + + void prepare (double sampleRate); + void reset(); + + // Process one sample: sidechainDb = input level in dB + // Returns gain in linear scale (0..1) + float processSample (float sidechainDb, float thresholdDb, float ratio, + float attackSec, float releaseSec, bool dualRelease); + + float getGainReductionDb() const { return currentGrDb; } + +private: + double sr = 44100.0; + + // Gain smoothing state (dB domain) + double smoothedGrDb = 0.0; + + // Dual release state + double dualFastEnv = 0.0; // fast release envelope + double dualSlowEnv = 0.0; // slow release envelope + bool wasCompressing = false; + + float currentGrDb = 0.0f; + + // Soft-knee width + static constexpr float kneeWidthDb = 6.0f; + + // Gain computer: returns desired GR in dB (negative value) + float computeGainReduction (float inputDb, float thresholdDb, float ratio) const; + + // Coefficient helpers + double makeCoeff (double timeSec) const; +}; diff --git a/Source/VuMeter.h b/Source/VuMeter.h new file mode 100644 index 0000000..ba2503e --- /dev/null +++ b/Source/VuMeter.h @@ -0,0 +1,62 @@ +#pragma once +#include + +class VuMeter : public juce::Component +{ +public: + void setLevel (float left, float right) + { + if (left > peakL) peakL = left; + else peakL *= 0.995f; + if (right > peakR) peakR = right; + else peakR *= 0.995f; + + levelL = std::max (left, levelL * 0.85f); + levelR = std::max (right, levelR * 0.85f); + repaint(); + } + + void paint (juce::Graphics& g) override + { + auto bounds = getLocalBounds().toFloat().reduced (1); + float barGap = 2.0f; + float halfW = (bounds.getWidth() - barGap) / 2.0f; + auto leftBar = bounds.removeFromLeft (halfW); + bounds.removeFromLeft (barGap); + auto rightBar = bounds; + + drawBar (g, leftBar, levelL, peakL); + drawBar (g, rightBar, levelR, peakR); + } + +private: + float levelL = 0.0f, levelR = 0.0f; + float peakL = 0.0f, peakR = 0.0f; + + void drawBar (juce::Graphics& g, juce::Rectangle bar, float level, float peak) + { + g.setColour (juce::Colour (0xff111122)); + g.fillRoundedRectangle (bar, 2.0f); + + float displayLevel = std::pow (juce::jlimit (0.0f, 1.0f, level), 0.5f); + float h = bar.getHeight() * displayLevel; + auto filled = bar.withTop (bar.getBottom() - h); + + if (displayLevel < 0.6f) + g.setColour (juce::Colour (0xff00cc44)); + else if (displayLevel < 0.85f) + g.setColour (juce::Colour (0xffcccc00)); + else + g.setColour (juce::Colour (0xffff3333)); + + g.fillRoundedRectangle (filled, 2.0f); + + float displayPeak = std::pow (juce::jlimit (0.0f, 1.0f, peak), 0.5f); + if (displayPeak > 0.01f) + { + float peakY = bar.getBottom() - bar.getHeight() * displayPeak; + g.setColour (juce::Colours::white.withAlpha (0.8f)); + g.fillRect (bar.getX(), peakY, bar.getWidth(), 1.5f); + } + } +};