feat: run the per-voice filter between the pitch and amp stages, with its own deck

Params ride the one parameter set; payload v8 -> v9, off by default.
Deck composition moves to a pure deck_groups module in pitch -> filter -> amp order.
This commit is contained in:
2026-07-30 15:26:14 -04:00
parent c9c708a338
commit 67215509cb
25 changed files with 1354 additions and 191 deletions
+92 -64
View File
@@ -12,11 +12,13 @@
#include <string>
#include <vector>
#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
#include "core/util/clamp01.h"
#include "shell/instrument/editor_internal.h" // DeckGroup ids
#include "shell/instrument/editor_internal.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
@@ -28,9 +30,16 @@ using instrument::ui::chromeRects;
using instrument::ui::deckHeight;
using instrument::ui::kDeckKnobSize;
using instrument::ui::kPad;
using instrument::ui::deckBipolarFromNorm;
using instrument::ui::deckNormFromBipolar;
using instrument::ui::sampleDeckGroups;
using instrument::engine::formatMasterGainLabel;
using instrument::engine::masterGainLinearFromNorm;
using instrument::engine::masterGainNormFromLinear;
using instrument::engine::filter::MorphLaw;
using instrument::engine::filter::filterCutoffHzFromNorm;
using instrument::engine::filter::filterDriveDepthFromNorm;
using instrument::engine::filter::filterQFromNorm;
using util::clamp01;
namespace {
@@ -53,7 +62,7 @@ ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
// chrome interior, and the deck descriptors can never be derived three different ways.
// The deck's own wrapped height is the only interior measurement the allocator needs.
FaceLayout fl;
fl.deckDescs = deckGroupDescs(params_.play);
fl.deckDescs = sampleDeckGroups(params_.play.playMode);
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad));
fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
return fl;
@@ -87,6 +96,23 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
case ParamControl::kPitchEnvDepth:
// Signed depth centered at 0.5 (0.5 == 0 semitones).
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
// Filter. The four tone controls ARE the module's normalized positions — stored and
// shown as-is, so the knob travel is exactly filter_params' own law.
case ParamControl::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0;
case ParamControl::kFilterLaw:
return play.filter.settings.morphLaw == MorphLaw::HighNotchLow ? 1.0 : 0.0;
case ParamControl::kFilterMorph: return clamp01(play.filter.settings.morphNorm);
case ParamControl::kFilterCutoff: return clamp01(play.filter.settings.cutoffNorm);
case ParamControl::kFilterQ: return clamp01(play.filter.settings.resonanceNorm);
case ParamControl::kFilterDrive: return clamp01(play.filter.settings.driveNorm);
case ParamControl::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
case ParamControl::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
case ParamControl::kFilterKeyTrack:return clamp01(play.filter.keyTrack / kKeyTrackMax);
case ParamControl::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds);
case ParamControl::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds);
case ParamControl::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds);
case ParamControl::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
case ParamControl::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
default: return 0.0;
}
}
@@ -126,6 +152,33 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
case ParamControl::kPitchEnvDepth:
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
break;
case ParamControl::kFilterEnable: play.filter.enabled = (segment == 1); break;
case ParamControl::kFilterLaw:
play.filter.settings.morphLaw =
(segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow;
break;
case ParamControl::kFilterMorph:
play.filter.settings.morphNorm = static_cast<float>(clamp01(value)); break;
case ParamControl::kFilterCutoff:
play.filter.settings.cutoffNorm = static_cast<float>(clamp01(value)); break;
case ParamControl::kFilterQ:
play.filter.settings.resonanceNorm = static_cast<float>(clamp01(value)); break;
case ParamControl::kFilterDrive:
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
case ParamControl::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
case ParamControl::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
case ParamControl::kFilterKeyTrack:
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
case ParamControl::kFilterEnvAttack:
play.filter.env.attackSeconds = normToSec(value); break;
case ParamControl::kFilterEnvHold:
play.filter.env.holdSeconds = normToSec(value); break;
case ParamControl::kFilterEnvDecay:
play.filter.env.decaySeconds = normToSec(value); break;
case ParamControl::kFilterEnvSustain:
play.filter.env.sustainLevel = clamp01(value); break;
case ParamControl::kFilterEnvRelease:
play.filter.env.releaseSeconds = normToSec(value); break;
default: break;
}
}
@@ -151,68 +204,6 @@ double ReaSamplerEditor::previewVelocity01() const {
return static_cast<double>(processor_->previewVelocity()) / 127.0;
}
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const PlaySeconds& play) const {
// The deck band's groups, left to right. Group widths are mode-independent: AMP ENVELOPE
// reserves its 5-cell Gate width (Trigger leaves two blank cells), so a Gate<->Trigger
// flip repopulates in place and never reflows the neighbouring groups.
std::vector<DeckGroupDesc> out;
{
DeckGroupDesc amp;
amp.id = kGroupAmpEnv;
amp.captionWidth = 78;
amp.captionToggle = {static_cast<int>(ParamControl::kPlayMode), 44};
if (play.playMode == PlayMode::Gate) {
amp.cellIds = {static_cast<int>(ParamControl::kAttack),
static_cast<int>(ParamControl::kHold),
static_cast<int>(ParamControl::kDecay),
static_cast<int>(ParamControl::kSustain),
static_cast<int>(ParamControl::kRelease)};
} else {
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
// the drawn envelope), plus the two reserved blanks.
amp.cellIds = {static_cast<int>(ParamControl::kTrigFadeIn),
static_cast<int>(ParamControl::kTrigLength),
static_cast<int>(ParamControl::kTrigFadeOut), -1, -1};
}
out.push_back(std::move(amp));
}
{
DeckGroupDesc pitch;
pitch.id = kGroupPitch;
pitch.captionWidth = 38;
pitch.captionToggle = {static_cast<int>(ParamControl::kPitchEngine), 48};
pitch.cellIds = {static_cast<int>(ParamControl::kKeyTrack)};
out.push_back(std::move(pitch));
}
{
DeckGroupDesc penv;
penv.id = kGroupPitchEnv;
penv.captionWidth = 58;
penv.captionToggle = {static_cast<int>(ParamControl::kPitchEnvEnable), 32};
penv.cellIds = {static_cast<int>(ParamControl::kPitchEnvAttack),
static_cast<int>(ParamControl::kPitchEnvDecay),
static_cast<int>(ParamControl::kPitchEnvDepth)};
out.push_back(std::move(penv));
}
{
DeckGroupDesc voice;
voice.id = kGroupVoice;
voice.captionWidth = 38;
voice.captionToggle = {static_cast<int>(ParamControl::kVoiceMode), 40};
voice.cellIds = {static_cast<int>(ParamControl::kVoiceCount)};
voice.rowToggle = {static_cast<int>(ParamControl::kMonoTrigger), 44};
out.push_back(std::move(voice));
}
{
DeckGroupDesc master;
master.id = kGroupMaster;
master.captionWidth = 46;
master.cellIds = {static_cast<int>(ParamControl::kMasterGain)};
out.push_back(std::move(master));
}
return out;
}
double ReaSamplerEditor::deckControlNorm(int id) const {
if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob
switch (static_cast<ParamControl>(id)) {
@@ -294,6 +285,43 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
case ParamControl::kMasterGain:
formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break;
// Filter readouts run the stored normalized positions back through the module's OWN
// laws, so what the label says is what the kernel is solved for.
case ParamControl::kFilterMorph: {
const double m = play.filter.settings.morphNorm;
snprintf(buf, sizeof(buf), "%.0f%%", m * 100.0);
break;
}
case ParamControl::kFilterCutoff: {
const float hz = filterCutoffHzFromNorm(play.filter.settings.cutoffNorm);
if (hz >= 1000.0f) snprintf(buf, sizeof(buf), "%.2fk", hz / 1000.0f);
else snprintf(buf, sizeof(buf), "%.0fHz", hz);
break;
}
case ParamControl::kFilterQ:
snprintf(buf, sizeof(buf), "%.2f",
static_cast<double>(filterQFromNorm(play.filter.settings.resonanceNorm)));
break;
case ParamControl::kFilterDrive:
snprintf(buf, sizeof(buf), "%.2f",
static_cast<double>(filterDriveDepthFromNorm(play.filter.settings.driveNorm)));
break;
case ParamControl::kFilterModAmt:
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.modAmount * 100.0); break;
case ParamControl::kFilterVel:
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.velAmount * 100.0); break;
case ParamControl::kFilterKeyTrack:
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.keyTrack * 100.0); break;
case ParamControl::kFilterEnvAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.attackSeconds); break;
case ParamControl::kFilterEnvHold:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.holdSeconds); break;
case ParamControl::kFilterEnvDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.decaySeconds); break;
case ParamControl::kFilterEnvSustain:
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.env.sustainLevel * 100.0); break;
case ParamControl::kFilterEnvRelease:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.releaseSeconds); break;
default:
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
break;