Add per-note velocity routing to filter cutoff and resonance

This commit is contained in:
Armin 2026-08-30 00:01:53 +02:00
commit 9ef77e04ab
6 changed files with 63 additions and 20 deletions

View file

@ -106,6 +106,8 @@ Osc1 + Osc2 → Amplitude Envelope × Velocity → Filter (cutoff modulated by F
| filterRes | Filter Res | 0..1 | 0.3 | | filterRes | Filter Res | 0..1 | 0.3 |
| filterEnvAmt | Filter Env Amt | 0..1 | 0 | | filterEnvAmt | Filter Env Amt | 0..1 | 0 |
| keyTrack | Key Tracking | 0..1 | 0.5 | | 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 | | fEnvAttack | Filter Attack | 0.001..5 (log) | 0.01 |
| fEnvDecay | Filter Decay | 0.001..5 (log) | 0.3 | | fEnvDecay | Filter Decay | 0.001..5 (log) | 0.3 |
| fEnvSustain | Filter Sustain | 0..1 | 0.5 | | fEnvSustain | Filter Sustain | 0..1 | 0.5 |
@ -198,6 +200,12 @@ ChromaFlockEditor (juce::AudioProcessorEditor)
### Filter Crash Fix ### 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. `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 ### 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 (13, clamped to MIDI range), and sequences it per pattern. - 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 (13, 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). - 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).

View file

@ -128,13 +128,19 @@ public:
left *= noteVelocity * envSample; left *= noteVelocity * envSample;
right *= 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) // Unipolar modulation UP from the base cutoff: the envelope (0..1)
// multiplied by env amount adds brightness on top of the cutoff // multiplied by env amount adds brightness on top of the cutoff
// knob, never going below it. Every env knob (attack/decay/ // knob, never going below it. The env amount is also scaled by
// sustain/release) shapes the response, and at env amount 0 the // note velocity, so harder hits open the filter more. Every env
// cutoff equals the knob. // knob (attack/decay/sustain/release) shapes the response, and at
float modCutoff = filterCutoff * std::pow(2.0f, fEnvSample * filterEnvAmount * 4.0f); // 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<float>(currentMidiNote) - 60.0f) / 12.0f * keyTrack); float keyTrackFactor = std::pow(2.0f, (static_cast<float>(currentMidiNote) - 60.0f) / 12.0f * keyTrack);
modCutoff *= keyTrackFactor; modCutoff *= keyTrackFactor;
@ -146,8 +152,10 @@ public:
modCutoff = std::clamp(modCutoff, 20.0f, static_cast<float>(getSampleRate() * 0.49)); modCutoff = std::clamp(modCutoff, 20.0f, static_cast<float>(getSampleRate() * 0.49));
filter.setCoefficients(modCutoff, filterResonance, filterType); float noteRes = std::clamp(filterResonance * (1.0f + velToResonance * noteVelocity), 0.0f, 1.0f);
filterR.setCoefficients(modCutoff, filterResonance, filterType);
filter.setCoefficients(modCutoff, noteRes, filterType);
filterR.setCoefficients(modCutoff, noteRes, filterType);
left = filter.process(left); left = filter.process(left);
right = filterR.process(right); right = filterR.process(right);
@ -180,7 +188,8 @@ public:
float fAtt, float fDec, float fSus, float fRel, float fAtt, float fDec, float fSus, float fRel,
float p, float drv, float outLvl, float p, float drv, float outLvl,
float l1Rate, float l1Depth, int l1Shape, int l1Dest, 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; waveform1 = wf1;
waveform2 = wf2; waveform2 = wf2;
octave1 = oct1; octave1 = oct1;
@ -197,6 +206,8 @@ public:
filterType = ft; filterType = ft;
filterEnvAmount = fEnvAmt; filterEnvAmount = fEnvAmt;
keyTrack = kTrack; keyTrack = kTrack;
velToCutoff = velCut;
velToResonance = velRes;
pan = p; pan = p;
drive = std::max(drv, 1.001f); drive = std::max(drv, 1.001f);
outputLevel = outLvl * outLvl; outputLevel = outLvl * outLvl;
@ -251,6 +262,8 @@ private:
FilterType filterType = FilterType::LowPass12; FilterType filterType = FilterType::LowPass12;
float filterEnvAmount = 0.0f; float filterEnvAmount = 0.0f;
float keyTrack = 0.5f; float keyTrack = 0.5f;
float velToCutoff = 0.0f;
float velToResonance = 0.0f;
float pan = 0.0f; float pan = 0.0f;
float drive = 1.5f; float drive = 1.5f;
float outputLevel = 0.8f; float outputLevel = 0.8f;

View file

@ -640,6 +640,8 @@ MainContentComponent::MainContentComponent(ChromaFlockProcessor& p)
setupParam(filterResKnob, filterResAttach, "filterRes", "RES"); setupParam(filterResKnob, filterResAttach, "filterRes", "RES");
setupParam(filterEnvAmtKnob, filterEnvAmtAttach, "filterEnvAmt", "ENV AMT"); setupParam(filterEnvAmtKnob, filterEnvAmtAttach, "filterEnvAmt", "ENV AMT");
setupParam(keyTrackKnob, keyTrackAttach, "keyTrack", "KEY TRK"); setupParam(keyTrackKnob, keyTrackAttach, "keyTrack", "KEY TRK");
setupParam(velToCutKnob, velToCutAttach, "velToCut", "VEL CUT");
setupParam(velToResKnob, velToResAttach, "velToRes", "VEL RES");
setupLabel(envLabel, "AMP ENV"); setupLabel(envLabel, "AMP ENV");
setupParam(envAttackKnob, envAttackAttach, "envAttack", "ATTACK"); setupParam(envAttackKnob, envAttackAttach, "envAttack", "ATTACK");
@ -1247,12 +1249,23 @@ void MainContentComponent::resized() {
phaseOffsetKnob.setBounds(528 + (knobSize + knobSpacing) * 4, knobY2, knobSize, knobSize); phaseOffsetKnob.setBounds(528 + (knobSize + knobSpacing) * 4, knobY2, knobSize, knobSize);
filterLabel.setBounds(1152, sectionY, 498, labelH); filterLabel.setBounds(1152, sectionY, 498, labelH);
filterTypeBox.setBounds(1162, sectionY + labelH + 4, 200, 28); filterTypeBox.setBounds(1430, 88, 220, 28);
int knobYF = sectionY + labelH + comboH + 8; // FILTER holds 6 knobs (incl. per-note VEL CUT / VEL RES), so the type
filterCutoffKnob.setBounds(1162, knobYF, knobSize, knobSize); // dropdown sits in the header row and the knobs form a 3x2 grid.
filterResKnob.setBounds(1162 + knobSize + knobSpacing, knobYF, knobSize, knobSize); {
filterEnvAmtKnob.setBounds(1162 + (knobSize + knobSpacing) * 2, knobYF, knobSize, knobSize); int fSize = 78;
keyTrackKnob.setBounds(1162 + (knobSize + knobSpacing) * 3, knobYF, knobSize, knobSize); 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); envLabel.setBounds(10, 302, 563, labelH);
{ {

View file

@ -168,7 +168,8 @@ private:
KnobSlider osc2OctKnob, osc2SemiKnob, osc2FineKnob, osc2LevelKnob, phaseOffsetKnob; KnobSlider osc2OctKnob, osc2SemiKnob, osc2FineKnob, osc2LevelKnob, phaseOffsetKnob;
juce::ComboBox filterTypeBox; juce::ComboBox filterTypeBox;
KnobSlider filterCutoffKnob, filterResKnob, filterEnvAmtKnob, keyTrackKnob; KnobSlider filterCutoffKnob, filterResKnob, filterEnvAmtKnob, keyTrackKnob,
velToCutKnob, velToResKnob;
KnobSlider envAttackKnob, envDecayKnob, envSustainKnob, envReleaseKnob; KnobSlider envAttackKnob, envDecayKnob, envSustainKnob, envReleaseKnob;
KnobSlider fEnvAttackKnob, fEnvDecayKnob, fEnvSustainKnob, fEnvReleaseKnob; KnobSlider fEnvAttackKnob, fEnvDecayKnob, fEnvSustainKnob, fEnvReleaseKnob;
@ -270,7 +271,7 @@ private:
std::unique_ptr<ComboBoxAttachment> osc1WaveAttach, osc2WaveAttach; std::unique_ptr<ComboBoxAttachment> osc1WaveAttach, osc2WaveAttach;
std::unique_ptr<IndexedComboBoxAttachment> filterTypeAttach; std::unique_ptr<IndexedComboBoxAttachment> filterTypeAttach;
std::unique_ptr<ComboBoxAttachment> octaveTransposeAttach, semitoneTransposeAttach; std::unique_ptr<ComboBoxAttachment> octaveTransposeAttach, semitoneTransposeAttach;
std::unique_ptr<SliderAttachment> filterCutoffAttach, filterResAttach, filterEnvAmtAttach, keyTrackAttach; std::unique_ptr<SliderAttachment> filterCutoffAttach, filterResAttach, filterEnvAmtAttach, keyTrackAttach, velToCutAttach, velToResAttach;
std::unique_ptr<SliderAttachment> envAttackAttach, envDecayAttach, envSustainAttach, envReleaseAttach; std::unique_ptr<SliderAttachment> envAttackAttach, envDecayAttach, envSustainAttach, envReleaseAttach;
std::unique_ptr<SliderAttachment> fEnvAttackAttach, fEnvDecayAttach, fEnvSustainAttach, fEnvReleaseAttach; std::unique_ptr<SliderAttachment> fEnvAttackAttach, fEnvDecayAttach, fEnvSustainAttach, fEnvReleaseAttach;
std::unique_ptr<SliderAttachment> panAttach, driveAttach, masterAttach, pbRangeAttach; std::unique_ptr<SliderAttachment> panAttach, driveAttach, masterAttach, pbRangeAttach;

View file

@ -107,6 +107,10 @@ juce::AudioProcessorValueTreeState::ParameterLayout ChromaFlockProcessor::create
juce::ParameterID{"filterEnvAmt", 1}, "Filter Env Amount", zeroOne, 0.0f)); juce::ParameterID{"filterEnvAmt", 1}, "Filter Env Amount", zeroOne, 0.0f));
layout.add(std::make_unique<juce::AudioParameterFloat>( layout.add(std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"keyTrack", 1}, "Key Tracking", zeroOne, 0.5f)); juce::ParameterID{"keyTrack", 1}, "Key Tracking", zeroOne, 0.5f));
layout.add(std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"velToCut", 1}, "Vel -> Cutoff", zeroOne, 0.0f));
layout.add(std::make_unique<juce::AudioParameterFloat>(
juce::ParameterID{"velToRes", 1}, "Vel -> Resonance", zeroOne, 0.0f));
// FILTER ENVELOPE // FILTER ENVELOPE
layout.add(std::make_unique<juce::AudioParameterFloat>( layout.add(std::make_unique<juce::AudioParameterFloat>(
@ -370,7 +374,7 @@ void ChromaFlockProcessor::updateVoiceParameters() {
getParam("phaseOffset"), getParam("phaseOffset"),
getParam("filterCutoff"), getParam("filterCutoff"),
getParam("filterRes"), getParam("filterRes"),
static_cast<FilterType>(static_cast<int>(getParam("filterType"))), static_cast<FilterType>(static_cast<int>(getParam("filterType"))),
getParam("filterEnvAmt"), getParam("filterEnvAmt"),
getParam("keyTrack"), getParam("keyTrack"),
getParam("envAttack"), getParam("envAttack"),
@ -391,7 +395,9 @@ void ChromaFlockProcessor::updateVoiceParameters() {
l2Rate, l2Rate,
getParam("lfo2Depth"), getParam("lfo2Depth"),
static_cast<int>(getParam("lfo2Shape")), static_cast<int>(getParam("lfo2Shape")),
static_cast<int>(getParam("lfo2Dest")) static_cast<int>(getParam("lfo2Dest")),
getParam("velToCut"),
getParam("velToRes")
); );
} }
} }

View file

@ -94,7 +94,9 @@ void renderPreset(const Preset& preset, Result& out) {
param(preset, "lfo2Rate", 2.0f), param(preset, "lfo2Rate", 2.0f),
param(preset, "lfo2Depth", 0.0f), param(preset, "lfo2Depth", 0.0f),
static_cast<int>(paramInt(preset, "lfo2Shape", 0)), static_cast<int>(paramInt(preset, "lfo2Shape", 0)),
static_cast<int>(paramInt(preset, "lfo2Dest", 0))); static_cast<int>(paramInt(preset, "lfo2Dest", 0)),
param(preset, "velToCut", 0.0f),
param(preset, "velToRes", 0.0f));
Distortion distortion; Distortion distortion;
Compression compression; Compression compression;