From 9ef77e04abd90172899f722234c45674e9594e13 Mon Sep 17 00:00:00 2001 From: Armin Date: Sun, 30 Aug 2026 00:01:53 +0200 Subject: [PATCH] Add per-note velocity routing to filter cutoff and resonance --- AGENTS.md | 8 ++++++++ Source/DSP/Voice.h | 29 +++++++++++++++++++++-------- Source/PluginEditor.cpp | 25 +++++++++++++++++++------ Source/PluginEditor.h | 5 +++-- Source/PluginProcessor.cpp | 12 +++++++++--- Tools/LevelNormalizer.cpp | 4 +++- 6 files changed, 63 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 522887c..31f8864 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,8 @@ Osc1 + Osc2 → Amplitude Envelope × Velocity → Filter (cutoff modulated by F | filterRes | Filter Res | 0..1 | 0.3 | | filterEnvAmt | Filter Env Amt | 0..1 | 0 | | keyTrack | Key Tracking | 0..1 | 0.5 | +| velToCut | Vel → Cutoff | 0..1 | 0 | +| velToRes | Vel → Resonance | 0..1 | 0 | | fEnvAttack | Filter Attack | 0.001..5 (log) | 0.01 | | fEnvDecay | Filter Decay | 0.001..5 (log) | 0.3 | | fEnvSustain | Filter Sustain | 0..1 | 0.5 | @@ -198,6 +200,12 @@ ChromaFlockEditor (juce::AudioProcessorEditor) ### Filter Crash Fix `SubtractiveVoice` now has separate `filter` (L) and `filterR` (R) instances; `startNote()` prepares both. Resolves the state-sharing crash when filter env amount > 0. +### Per-Note Filter Velocity Routing +- `VEL CUT` (`velToCut`) and `VEL RES` (`velToRes`) knobs map each note's velocity to filter cutoff / resonance. Velocity is the only per-note MIDI data a DAW sequencer sends, so cutoff & resonance become drawable per note in the DAW's piano-roll velocity lane — no built-in step sequencer added. +- Cutoff path is envelope-independent: `modCutoff *= 2^(velToCut * noteVelocity * 4)` (up to +4 octaves at full depth/velocity). Resonance: `res * (1 + velToRes * noteVelocity)`, clamped to 0..1. +- Works through the arpeggiator (held-note velocities are forwarded) and the plugin's own piano roll (sends 0.8). +- FILTER section layout: type dropdown moved into the header row, knobs now a 3x2 grid (CUTOFF/RES/ENV AMT over KEY TRK/VEL CUT/VEL RES). + ### Arpeggiator - New class `DSP/Arpeggiator.h` — tempo-synced, pure note-logic (no audio). Tracks held notes (press order for the As Played pattern), builds a pitch pool from held notes × octave range (1–3, clamped to MIDI range), and sequences it per pattern. - Patterns: Up, Down, UpDown, DownUp, Random, As Played, Chord, Up&Down X, Down&Up X, Random Once, Octave Up, Octave Down, Pinky Up, Pinky Down. X variants are endpoint-exclusive bounces; Random Once shuffles per cycle (no repeat until full pass); Octave Up/Down play each root followed by its ±12 copies spanning the selected OCTAVES count; Pinky Up/Down alternate extremes low/high. Up arpeggiate a minor third + fifth (root, +3, +7) and the Up major variant a major third + fifth (root, +4, +7); UpDown/DownUp arpeggiate major (root, +4, +7) plus an UpDown minor variant (root, +3, +7), each as a separate step. Dropdown labels: "Up / Minor", "Down / Minor", "Up & Down / Major", "Down & Up / Major", "Up / Major", "Down / Major", "Up & Down / Minor". "Down" patterns always descend from each held note through the chord tones below it (root, fifth below, third below, next-octave root — e.g. C3, G2, E2, C2 for major), never playing above the held note; any harmony pattern does the same when DIR is set to Down. C3/C4 1/2/3 anchor to the most recently held key and play fixed 8-step loops alternating it and its octave above (3-3-4-3-4-3-3-4, 4-3-3-3-3-3-3-3, 3-3-3-4-3-3-3-3). diff --git a/Source/DSP/Voice.h b/Source/DSP/Voice.h index c0e6462..7d3eb6d 100644 --- a/Source/DSP/Voice.h +++ b/Source/DSP/Voice.h @@ -128,13 +128,19 @@ public: left *= noteVelocity * envSample; right *= noteVelocity * envSample; - // Filter cutoff with env + keytrack. + // Filter cutoff with env + keytrack + velocity. // Unipolar modulation UP from the base cutoff: the envelope (0..1) // multiplied by env amount adds brightness on top of the cutoff - // knob, never going below it. Every env knob (attack/decay/ - // sustain/release) shapes the response, and at env amount 0 the - // cutoff equals the knob. - float modCutoff = filterCutoff * std::pow(2.0f, fEnvSample * filterEnvAmount * 4.0f); + // knob, never going below it. The env amount is also scaled by + // note velocity, so harder hits open the filter more. Every env + // knob (attack/decay/sustain/release) shapes the response, and at + // env amount 0 the cutoff equals the knob. The independent VEL CUT + // depth adds an envelope-free velocity path and VEL RES maps note + // velocity to resonance; together they let cutoff and resonance be + // drawn per note from the DAW sequencer (velocity is the only + // per-note MIDI data a DAW sends). + float modCutoff = filterCutoff * std::pow(2.0f, fEnvSample * filterEnvAmount * noteVelocity * 4.0f); + modCutoff *= std::pow(2.0f, velToCutoff * noteVelocity * 4.0f); float keyTrackFactor = std::pow(2.0f, (static_cast(currentMidiNote) - 60.0f) / 12.0f * keyTrack); modCutoff *= keyTrackFactor; @@ -146,8 +152,10 @@ public: modCutoff = std::clamp(modCutoff, 20.0f, static_cast(getSampleRate() * 0.49)); - filter.setCoefficients(modCutoff, filterResonance, filterType); - filterR.setCoefficients(modCutoff, filterResonance, filterType); + float noteRes = std::clamp(filterResonance * (1.0f + velToResonance * noteVelocity), 0.0f, 1.0f); + + filter.setCoefficients(modCutoff, noteRes, filterType); + filterR.setCoefficients(modCutoff, noteRes, filterType); left = filter.process(left); right = filterR.process(right); @@ -180,7 +188,8 @@ public: float fAtt, float fDec, float fSus, float fRel, float p, float drv, float outLvl, float l1Rate, float l1Depth, int l1Shape, int l1Dest, - float l2Rate, float l2Depth, int l2Shape, int l2Dest) { + float l2Rate, float l2Depth, int l2Shape, int l2Dest, + float velCut, float velRes) { waveform1 = wf1; waveform2 = wf2; octave1 = oct1; @@ -197,6 +206,8 @@ public: filterType = ft; filterEnvAmount = fEnvAmt; keyTrack = kTrack; + velToCutoff = velCut; + velToResonance = velRes; pan = p; drive = std::max(drv, 1.001f); outputLevel = outLvl * outLvl; @@ -251,6 +262,8 @@ private: FilterType filterType = FilterType::LowPass12; float filterEnvAmount = 0.0f; float keyTrack = 0.5f; + float velToCutoff = 0.0f; + float velToResonance = 0.0f; float pan = 0.0f; float drive = 1.5f; float outputLevel = 0.8f; diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index 1725ab3..5d99ca7 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -640,6 +640,8 @@ MainContentComponent::MainContentComponent(ChromaFlockProcessor& p) setupParam(filterResKnob, filterResAttach, "filterRes", "RES"); setupParam(filterEnvAmtKnob, filterEnvAmtAttach, "filterEnvAmt", "ENV AMT"); setupParam(keyTrackKnob, keyTrackAttach, "keyTrack", "KEY TRK"); + setupParam(velToCutKnob, velToCutAttach, "velToCut", "VEL CUT"); + setupParam(velToResKnob, velToResAttach, "velToRes", "VEL RES"); setupLabel(envLabel, "AMP ENV"); setupParam(envAttackKnob, envAttackAttach, "envAttack", "ATTACK"); @@ -1247,12 +1249,23 @@ void MainContentComponent::resized() { phaseOffsetKnob.setBounds(528 + (knobSize + knobSpacing) * 4, knobY2, knobSize, knobSize); filterLabel.setBounds(1152, sectionY, 498, labelH); - filterTypeBox.setBounds(1162, sectionY + labelH + 4, 200, 28); - int knobYF = sectionY + labelH + comboH + 8; - filterCutoffKnob.setBounds(1162, knobYF, knobSize, knobSize); - filterResKnob.setBounds(1162 + knobSize + knobSpacing, knobYF, knobSize, knobSize); - filterEnvAmtKnob.setBounds(1162 + (knobSize + knobSpacing) * 2, knobYF, knobSize, knobSize); - keyTrackKnob.setBounds(1162 + (knobSize + knobSpacing) * 3, knobYF, knobSize, knobSize); + filterTypeBox.setBounds(1430, 88, 220, 28); + // FILTER holds 6 knobs (incl. per-note VEL CUT / VEL RES), so the type + // dropdown sits in the header row and the knobs form a 3x2 grid. + { + int fSize = 78; + int fGap = 6; + int gridW = 3 * fSize + 2 * fGap; + int fSx = 1152 + (498 - gridW) / 2; + int fRow1Y = 124; + int fRow2Y = fRow1Y + fSize + fGap; + filterCutoffKnob.setBounds(fSx, fRow1Y, fSize, fSize); + filterResKnob.setBounds(fSx + fSize + fGap, fRow1Y, fSize, fSize); + filterEnvAmtKnob.setBounds(fSx + (fSize + fGap) * 2, fRow1Y, fSize, fSize); + keyTrackKnob.setBounds(fSx, fRow2Y, fSize, fSize); + velToCutKnob.setBounds(fSx + fSize + fGap, fRow2Y, fSize, fSize); + velToResKnob.setBounds(fSx + (fSize + fGap) * 2, fRow2Y, fSize, fSize); + } envLabel.setBounds(10, 302, 563, labelH); { diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index e3b19c5..94b0e36 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -168,7 +168,8 @@ private: KnobSlider osc2OctKnob, osc2SemiKnob, osc2FineKnob, osc2LevelKnob, phaseOffsetKnob; juce::ComboBox filterTypeBox; - KnobSlider filterCutoffKnob, filterResKnob, filterEnvAmtKnob, keyTrackKnob; + KnobSlider filterCutoffKnob, filterResKnob, filterEnvAmtKnob, keyTrackKnob, + velToCutKnob, velToResKnob; KnobSlider envAttackKnob, envDecayKnob, envSustainKnob, envReleaseKnob; KnobSlider fEnvAttackKnob, fEnvDecayKnob, fEnvSustainKnob, fEnvReleaseKnob; @@ -270,7 +271,7 @@ private: std::unique_ptr osc1WaveAttach, osc2WaveAttach; std::unique_ptr filterTypeAttach; std::unique_ptr octaveTransposeAttach, semitoneTransposeAttach; - std::unique_ptr filterCutoffAttach, filterResAttach, filterEnvAmtAttach, keyTrackAttach; + std::unique_ptr filterCutoffAttach, filterResAttach, filterEnvAmtAttach, keyTrackAttach, velToCutAttach, velToResAttach; std::unique_ptr envAttackAttach, envDecayAttach, envSustainAttach, envReleaseAttach; std::unique_ptr fEnvAttackAttach, fEnvDecayAttach, fEnvSustainAttach, fEnvReleaseAttach; std::unique_ptr panAttach, driveAttach, masterAttach, pbRangeAttach; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index 253d717..04074b3 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -107,6 +107,10 @@ juce::AudioProcessorValueTreeState::ParameterLayout ChromaFlockProcessor::create juce::ParameterID{"filterEnvAmt", 1}, "Filter Env Amount", zeroOne, 0.0f)); layout.add(std::make_unique( juce::ParameterID{"keyTrack", 1}, "Key Tracking", zeroOne, 0.5f)); + layout.add(std::make_unique( + juce::ParameterID{"velToCut", 1}, "Vel -> Cutoff", zeroOne, 0.0f)); + layout.add(std::make_unique( + juce::ParameterID{"velToRes", 1}, "Vel -> Resonance", zeroOne, 0.0f)); // FILTER ENVELOPE layout.add(std::make_unique( @@ -370,9 +374,9 @@ void ChromaFlockProcessor::updateVoiceParameters() { getParam("phaseOffset"), getParam("filterCutoff"), getParam("filterRes"), - static_cast(static_cast(getParam("filterType"))), +static_cast(static_cast(getParam("filterType"))), getParam("filterEnvAmt"), - getParam("keyTrack"), + getParam("keyTrack"), getParam("envAttack"), getParam("envDecay"), getParam("envSustain"), @@ -391,7 +395,9 @@ void ChromaFlockProcessor::updateVoiceParameters() { l2Rate, getParam("lfo2Depth"), static_cast(getParam("lfo2Shape")), - static_cast(getParam("lfo2Dest")) + static_cast(getParam("lfo2Dest")), + getParam("velToCut"), + getParam("velToRes") ); } } diff --git a/Tools/LevelNormalizer.cpp b/Tools/LevelNormalizer.cpp index e13792a..ed490cd 100644 --- a/Tools/LevelNormalizer.cpp +++ b/Tools/LevelNormalizer.cpp @@ -94,7 +94,9 @@ void renderPreset(const Preset& preset, Result& out) { param(preset, "lfo2Rate", 2.0f), param(preset, "lfo2Depth", 0.0f), static_cast(paramInt(preset, "lfo2Shape", 0)), - static_cast(paramInt(preset, "lfo2Dest", 0))); + static_cast(paramInt(preset, "lfo2Dest", 0)), + param(preset, "velToCut", 0.0f), + param(preset, "velToRes", 0.0f)); Distortion distortion; Compression compression;