feat: legible ReaSampler 9000 editor — bigger knobs, ms time constants, per-ring double-click reset, and an antialiased draw pass

This commit is contained in:
2026-08-01 09:16:30 -04:00
parent 213ecfafe6
commit 7f74b11dce
27 changed files with 859 additions and 285 deletions
+39 -214
View File
@@ -1,9 +1,9 @@
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
// resolve every paint/hit-test path shares, the control-value domain maps (controlValue /
// applyControl — seconds/fraction/frames <-> normalized 0..1), the control-id<->value binding
// against the pure `deck_groups` module's descriptors, and the node-drag clamp bounds that must
// match those domains. The orthogonal half — which stored struct each editor selection names —
// is editor_models. Value logic only: no painting, no window plumbing.
// resolve every paint/hit-test path shares, the shell's half of the control-value binding (the
// per-instance controls the parameter set does not carry — key-track, voice count, master gain,
// preview velocity — plus the value labels), and the node-drag clamp bounds. The parameter-set
// half is the pure `deck_values` module. The orthogonal half — which stored struct each editor
// selection names — is editor_models. Value logic only: no painting, no window plumbing.
#include "shell/instrument/reasampler_editor.h"
@@ -16,6 +16,7 @@
#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/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
#include "core/instrument/ui/deck_values.h" // the parameter-set binding + its ms units
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
#include "core/util/clamp01.h"
#include "core/util/curve_law.h" // the ONE curve-exponent domain
@@ -30,32 +31,22 @@ 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::deckParamNorm;
using instrument::ui::formatEnvTimeMs;
using instrument::ui::kEnvTimeMaxSeconds;
using instrument::ui::kKeyTrackMax;
using instrument::ui::resetDeckParam;
using instrument::ui::sampleDeckGroups;
using instrument::ui::setDeckParam;
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 {
// Control-surface value domains (the shell owns these — param_slider is engine-free and maps
// only 0..1). Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly
// what the parameter set stores; the build resolves seconds->frames at the live rate. No knob
// on this surface stores a source-frame count any more, so none needs a rate to draw.
//
// The ceiling is READ from the overlay's schematic scale rather than restated: the AHDSR
// schematic anchors a maxed knob at the canvas edge, which only holds while the two agree.
constexpr double kEnvTimeMaxSeconds = instrument::ui::kGateStageMaxSeconds;
// Pitch depth throw: +/-kVelocityPitchRangeSemitones, centered. The one throw the pitch
// envelope's peak and the velocity->pitch curve's full scale both speak (play_params.h).
constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones;
constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling (0..200%)
// The raw stored curve exponent for a curve-dial control id, read DIRECTLY off the field —
// never round-tripped through curveFromKnobNorm(knobNormFromCurve(x)): the knob-norm law's
// centre detent (curve_law.h) snaps anything near-neutral back to exactly 1.0, so a round trip
@@ -91,191 +82,25 @@ ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
return fl;
}
// The parameter-set binding is the pure deck_values module's; these three are the shell's thin
// int-id adapters onto it (ParamControl is an alias of DeckParam).
double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
// Wall-clock seconds -> normalized over the seconds ceiling; fractions and normalized
// control positions pass through; curve exponents take the log travel.
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
case ParamControl::kAmpEnvMode:
return play.ampSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case ParamControl::kPitchEnvMode:
return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case ParamControl::kFilterEnvMode:
return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds);
case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds);
case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds);
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds);
case ParamControl::kAttackCurve: return util::knobNormFromCurve(play.adsr.attackCurve);
case ParamControl::kDecayCurve: return util::knobNormFromCurve(play.adsr.decayCurve);
case ParamControl::kReleaseCurve: return util::knobNormFromCurve(play.adsr.releaseCurve);
case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction);
case ParamControl::kTrigAttack: return secToNorm(play.trigAhd.attackSeconds);
case ParamControl::kTrigHold: return clamp01(play.trigAhd.holdFraction);
case ParamControl::kTrigDecay: return secToNorm(play.trigAhd.decaySeconds);
case ParamControl::kTrigAttackCurve: return util::knobNormFromCurve(play.trigAhd.attackCurve);
case ParamControl::kTrigDecayCurve: return util::knobNormFromCurve(play.trigAhd.decayCurve);
case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.shape.attackSeconds);
case ParamControl::kPitchEnvHold: return clamp01(play.pitchEnv.shape.holdFraction);
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.shape.decaySeconds);
case ParamControl::kPitchEnvAttackCurve:
return util::knobNormFromCurve(play.pitchEnv.shape.attackCurve);
case ParamControl::kPitchEnvDecayCurve:
return util::knobNormFromCurve(play.pitchEnv.shape.decayCurve);
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);
case ParamControl::kFilterEnvAttackCurve:
return util::knobNormFromCurve(play.filter.env.attackCurve);
case ParamControl::kFilterEnvDecayCurve:
return util::knobNormFromCurve(play.filter.env.decayCurve);
case ParamControl::kFilterEnvReleaseCurve:
return util::knobNormFromCurve(play.filter.env.releaseCurve);
case ParamControl::kFilterTrigAttack: return secToNorm(play.filter.trigEnv.attackSeconds);
case ParamControl::kFilterTrigHold: return clamp01(play.filter.trigEnv.holdFraction);
case ParamControl::kFilterTrigDecay: return secToNorm(play.filter.trigEnv.decaySeconds);
case ParamControl::kFilterTrigAttackCurve:
return util::knobNormFromCurve(play.filter.trigEnv.attackCurve);
case ParamControl::kFilterTrigDecayCurve:
return util::knobNormFromCurve(play.filter.trigEnv.decayCurve);
default: return 0.0;
}
return deckParamNorm(static_cast<instrument::ui::DeckParam>(id), play);
}
void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
int segment) const {
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode:
// Gate is refused while any EG is drawn — see splineActive (play_params.h). The
// segment paints Disabled for the same reason, so the refusal is never a surprise.
if (segment == 0 && splineActive(play)) break;
play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
break;
// Switching TO Spline drops Gate, which the spline model has no place for. Switching
// back does NOT restore it: the previous mode is not stored, and silently re-gating an
// instrument the user has since heard as a one-shot is the worse surprise.
case ParamControl::kAmpEnvMode:
case ParamControl::kPitchEnvMode:
case ParamControl::kFilterEnvMode: {
const EnvMode m = (segment == 1) ? EnvMode::Spline : EnvMode::Staged;
switch (static_cast<ParamControl>(id)) {
case ParamControl::kAmpEnvMode: play.ampSpline.mode = m; break;
case ParamControl::kPitchEnvMode: play.pitchSpline.mode = m; break;
default: play.filterSpline.mode = m; break;
}
break;
}
case ParamControl::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
break;
case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break;
case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break;
case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break;
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
case ParamControl::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kTrigLength:
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
break;
case ParamControl::kTrigAttack: play.trigAhd.attackSeconds = normToSec(value); break;
case ParamControl::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
case ParamControl::kTrigDecay: play.trigAhd.decaySeconds = normToSec(value); break;
case ParamControl::kTrigAttackCurve:
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kTrigDecayCurve:
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kPitchEnvEnable:
play.pitchEnv.enabled = (segment == 1);
break;
case ParamControl::kPitchEnvAttack:
play.pitchEnv.shape.attackSeconds = normToSec(value); break;
case ParamControl::kPitchEnvHold:
play.pitchEnv.shape.holdFraction = clamp01(value); break;
case ParamControl::kPitchEnvDecay:
play.pitchEnv.shape.decaySeconds = normToSec(value); break;
case ParamControl::kPitchEnvAttackCurve:
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kPitchEnvDecayCurve:
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
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;
case ParamControl::kFilterEnvAttackCurve:
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kFilterEnvDecayCurve:
play.filter.env.decayCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kFilterEnvReleaseCurve:
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kFilterTrigAttack:
play.filter.trigEnv.attackSeconds = normToSec(value); break;
case ParamControl::kFilterTrigHold:
play.filter.trigEnv.holdFraction = clamp01(value); break;
case ParamControl::kFilterTrigDecay:
play.filter.trigEnv.decaySeconds = normToSec(value); break;
case ParamControl::kFilterTrigAttackCurve:
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kFilterTrigDecayCurve:
play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break;
default: break;
setDeckParam(static_cast<instrument::ui::DeckParam>(id), play, value, segment);
}
void ReaSamplerEditor::resetParamControl(int id) {
// kKeyTrack is the one knob whose value sits beside the play bundle, so it defaults from
// InstrumentParams rather than from PlaySeconds — the same split applyParamControl draws.
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
params_.keyTrack = InstrumentParams{}.keyTrack;
return;
}
// ONE normalization point for every control that can flip splineActive — a mode toggle
// (above) or an enable toggle (kPitchEnvEnable/kFilterEnable), whose enabling can make an
// already-Spline pitch/filter envelope newly active. Applying it once here, rather than at
// each site that could cause the flip, is what keeps a future such control from reopening
// the same hole. `resolvePlay` (sample_map.cpp) is the other caller of the shared helper.
enforceGateUnavailableWhileDrawn(play);
resetDeckParam(static_cast<instrument::ui::DeckParam>(id), params_.play);
}
double ReaSamplerEditor::liveSampleRate() const {
@@ -348,29 +173,29 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
const PlaySeconds& play = params_.play;
switch (id == -2 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
case ParamControl::kAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break;
formatEnvTimeMs(play.adsr.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kHold:
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.holdSeconds); break;
formatEnvTimeMs(play.adsr.holdSeconds, buf, sizeof(buf)); break;
case ParamControl::kDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.decaySeconds); break;
formatEnvTimeMs(play.adsr.decaySeconds, buf, sizeof(buf)); break;
case ParamControl::kSustain:
snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break;
case ParamControl::kRelease:
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break;
formatEnvTimeMs(play.adsr.releaseSeconds, buf, sizeof(buf)); break;
case ParamControl::kTrigLength:
snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break;
case ParamControl::kTrigAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.trigAhd.attackSeconds); break;
formatEnvTimeMs(play.trigAhd.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kTrigHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.trigAhd.holdFraction * 100.0); break;
case ParamControl::kTrigDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.trigAhd.decaySeconds); break;
formatEnvTimeMs(play.trigAhd.decaySeconds, buf, sizeof(buf)); break;
case ParamControl::kPitchEnvAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.shape.attackSeconds); break;
formatEnvTimeMs(play.pitchEnv.shape.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kPitchEnvHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.pitchEnv.shape.holdFraction * 100.0); break;
case ParamControl::kPitchEnvDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.shape.decaySeconds); break;
formatEnvTimeMs(play.pitchEnv.shape.decaySeconds, buf, sizeof(buf)); break;
case ParamControl::kPitchEnvDepth:
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
case ParamControl::kKeyTrack:
@@ -407,21 +232,21 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
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;
formatEnvTimeMs(play.filter.env.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterEnvHold:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.holdSeconds); break;
formatEnvTimeMs(play.filter.env.holdSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterEnvDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.decaySeconds); break;
formatEnvTimeMs(play.filter.env.decaySeconds, buf, sizeof(buf)); 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;
formatEnvTimeMs(play.filter.env.releaseSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterTrigAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.trigEnv.attackSeconds); break;
formatEnvTimeMs(play.filter.trigEnv.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kFilterTrigHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.trigEnv.holdFraction * 100.0); break;
case ParamControl::kFilterTrigDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.trigEnv.decaySeconds); break;
formatEnvTimeMs(play.filter.trigEnv.decaySeconds, buf, sizeof(buf)); break;
// Every curve exponent reads the same way: the neutral shows as 1.00.
case ParamControl::kAttackCurve:
case ParamControl::kDecayCurve: