285 lines
14 KiB
C++
285 lines
14 KiB
C++
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
|
|
// 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 each knob's plain value and its label), 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.
|
|
|
|
#include "shell/instrument/reasampler_editor.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath> // isfinite (the gain's -inf label)
|
|
#include <cstdint>
|
|
#include <cstdio> // snprintf (deck value labels)
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
|
|
#include "core/instrument/param/param_format.h" // THE formatter every value label reads through
|
|
#include "core/instrument/param/param_id.h" // whether a control has a parameter row at all
|
|
#include "core/instrument/ui/bake_hold.h" // the Hold knob's ladder map
|
|
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
|
|
#include "core/instrument/ui/deck_values.h" // the parameter-set binding
|
|
#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
|
|
#include "shell/instrument/editor_internal.h"
|
|
#include "shell/instrument/reasampler_processor.h"
|
|
|
|
namespace reasampler::vst {
|
|
|
|
using namespace reasampler::instrument::map; // PlaySeconds vocabulary
|
|
using instrument::ui::computeSampleBands;
|
|
using instrument::ui::chromeRects;
|
|
using instrument::ui::deckHeight;
|
|
using instrument::ui::kDeckKnobSize;
|
|
using instrument::ui::kPad;
|
|
using instrument::ui::deckParamNorm;
|
|
using instrument::ui::kEnvTimeMaxSeconds;
|
|
using instrument::ui::resetDeckParam;
|
|
using instrument::ui::sampleDeckGroups;
|
|
using instrument::ui::setDeckParam;
|
|
using instrument::engine::masterGainLinearFromNorm;
|
|
using instrument::engine::masterGainNormFromLinear;
|
|
using util::clamp01;
|
|
|
|
namespace {
|
|
// 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
|
|
// can misreport a stored exponent that isn't neutral as "^1.00".
|
|
double curveExponentFor(int id, const PlaySeconds& play) {
|
|
using DeckParam = instrument::ui::DeckParam; // ReaSamplerEditor::ParamControl is an alias
|
|
switch (static_cast<DeckParam>(id)) {
|
|
case DeckParam::kAttackCurve: return play.adsr.attackCurve;
|
|
case DeckParam::kDecayCurve: return play.adsr.decayCurve;
|
|
case DeckParam::kReleaseCurve: return play.adsr.releaseCurve;
|
|
case DeckParam::kTrigAttackCurve: return play.trigAhd.attackCurve;
|
|
case DeckParam::kTrigDecayCurve: return play.trigAhd.decayCurve;
|
|
case DeckParam::kPitchEnvAttackCurve: return play.pitchEnv.shape.attackCurve;
|
|
case DeckParam::kPitchEnvDecayCurve: return play.pitchEnv.shape.decayCurve;
|
|
case DeckParam::kFilterEnvAttackCurve: return play.filter.env.attackCurve;
|
|
case DeckParam::kFilterEnvDecayCurve: return play.filter.env.decayCurve;
|
|
case DeckParam::kFilterEnvReleaseCurve: return play.filter.env.releaseCurve;
|
|
case DeckParam::kFilterTrigAttackCurve: return play.filter.trigEnv.attackCurve;
|
|
case DeckParam::kFilterTrigDecayCurve: return play.filter.trigEnv.decayCurve;
|
|
default: return util::kCurveNeutral;
|
|
}
|
|
}
|
|
} // namespace
|
|
|
|
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
|
|
// The ONE resolve every paint and hit-test path goes through, so the band stack, the
|
|
// chrome interior, and the deck descriptors can never be derived three different ways.
|
|
// The deck's own height is the only interior measurement the allocator needs.
|
|
FaceLayout fl;
|
|
fl.deckDescs = sampleDeckGroups(params_.play.playMode);
|
|
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs));
|
|
fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
|
|
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 {
|
|
return deckParamNorm(static_cast<instrument::ui::DeckParam>(id), play);
|
|
}
|
|
|
|
void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
|
|
int segment) const {
|
|
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;
|
|
}
|
|
resetDeckParam(static_cast<instrument::ui::DeckParam>(id), params_.play);
|
|
}
|
|
|
|
double ReaSamplerEditor::liveSampleRate() const {
|
|
return processor_ ? processor_->sampleRate() : 0.0;
|
|
}
|
|
|
|
double ReaSamplerEditor::previewVelocity01() const {
|
|
if (!processor_) return static_cast<double>(kPreviewVelocityDefault) / 127.0;
|
|
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
|
}
|
|
|
|
double ReaSamplerEditor::bakeHoldNorm() const {
|
|
return instrument::ui::bakeHoldNorm(params_.bakeHold);
|
|
}
|
|
|
|
double ReaSamplerEditor::deckControlNorm(int id) const {
|
|
if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob
|
|
if (id == kBakeHoldKnobId) return bakeHoldNorm();
|
|
switch (static_cast<ParamControl>(id)) {
|
|
case ParamControl::kKeyTrack:
|
|
return instrument::ui::keyTrackNormFrom(params_.keyTrack);
|
|
case ParamControl::kVoiceCount:
|
|
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
|
|
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
|
|
case ParamControl::kMasterGain:
|
|
return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0);
|
|
default:
|
|
return controlValue(id, params_.play);
|
|
}
|
|
}
|
|
|
|
instrument::ui::DeckEnableState ReaSamplerEditor::deckEnableState() const {
|
|
const PlaySeconds& play = params_.play;
|
|
return instrument::ui::DeckEnableState{
|
|
play.pitchEnv.enabled, play.filter.enabled,
|
|
play.ampSpline.mode == EnvMode::Spline,
|
|
play.pitchSpline.mode == EnvMode::Spline,
|
|
play.filterSpline.mode == EnvMode::Spline};
|
|
}
|
|
|
|
bool ReaSamplerEditor::loopControlsLive() const {
|
|
return effectivePlayMode(params_.play) == PlayMode::Gate;
|
|
}
|
|
|
|
instrument::ui::WaveMarks ReaSamplerEditor::waveMarksFor(const SetupMarkers& m) const {
|
|
using instrument::ui::WaveMark;
|
|
instrument::ui::WaveMarks w;
|
|
w.frame[static_cast<int>(WaveMark::kStart)] = m.start;
|
|
w.frame[static_cast<int>(WaveMark::kLoopStart)] = m.loopStart;
|
|
w.frame[static_cast<int>(WaveMark::kLoopEnd)] = m.loopEnd;
|
|
// The crossfade grows LEFT from the seam it closes, which is where it is audible.
|
|
w.frame[static_cast<int>(WaveMark::kCrossfade)] = m.loopEnd - m.crossfade;
|
|
// Trigger has no loop at all, so the pair and the fade are ABSENT rather than shown in an
|
|
// off state — a mark whose gesture the mode does not offer was read as broken, not as off.
|
|
// Gate keeps the pair whatever the enable says: that is the drag-to-set-loop affordance.
|
|
// The crossfade mark belongs to an ACTIVE loop: with the enable off there is no seam for it
|
|
// to sit on, and no length to drag.
|
|
const bool gate = loopControlsLive();
|
|
w.present[static_cast<int>(WaveMark::kStart)] = true;
|
|
w.present[static_cast<int>(WaveMark::kLoopStart)] = gate;
|
|
w.present[static_cast<int>(WaveMark::kLoopEnd)] = gate;
|
|
w.present[static_cast<int>(WaveMark::kCrossfade)] = gate && m.hasLoop;
|
|
return w;
|
|
}
|
|
|
|
instrument::ui::WaveMarks ReaSamplerEditor::grabbableMarks(const SetupMarkers& m) const {
|
|
// Drawn IFF grabbable is the product rule, so this is an exact alias of waveMarksFor and
|
|
// cannot currently diverge from it. Kept as its own seam anyway because paint and hit-test
|
|
// are separate questions in principle — but do NOT re-add a suppression here: the
|
|
// Gate-with-loop-off marks are drawn grey precisely so they can still be dragged, and
|
|
// dragging one is what turns the enable on.
|
|
return waveMarksFor(m);
|
|
}
|
|
|
|
void ReaSamplerEditor::setLoopEnabled(bool on) {
|
|
const auto frames = static_cast<std::int64_t>(monoPcmFor(selectedId_).size());
|
|
if (frames <= 0) return;
|
|
SetupMarkers m = pickedMarkers(frames);
|
|
if (m.hasLoop == on) return; // a no-op commit would buy a re-decode for nothing
|
|
m.hasLoop = on;
|
|
applyMarkers(m);
|
|
commitAndReload();
|
|
}
|
|
|
|
void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
|
|
if (!processor_) return;
|
|
norm = clamp01(norm);
|
|
if (id == -2) {
|
|
// Preview velocity: live processor write (persisted per-instance; the setter clamps
|
|
// to MIDI 1..127 so the knob's bottom still strikes audibly).
|
|
processor_->setPreviewVelocity(static_cast<std::uint8_t>(norm * 127.0 + 0.5));
|
|
return;
|
|
}
|
|
if (id == kBakeHoldKnobId) {
|
|
// A parameter-set edit like the deck's own knobs: the commit lands on release.
|
|
params_.bakeHold = instrument::ui::bakeHoldFromNorm(norm);
|
|
return;
|
|
}
|
|
switch (static_cast<ParamControl>(id)) {
|
|
case ParamControl::kVoiceCount: {
|
|
// Stepped: quantize the continuous drag to the integer count and track it live
|
|
// for the label/needle. The actual engine rebuild (setVoiceCount) fires ONCE on
|
|
// WM_LBUTTONUP — not per step — so a full drag (~31 steps) costs one rebuild,
|
|
// not thirty.
|
|
const int count =
|
|
kMinVoiceCount +
|
|
static_cast<int>(norm * (kMaxVoiceCount - kMinVoiceCount) + 0.5);
|
|
voiceCount_ = count;
|
|
return;
|
|
}
|
|
case ParamControl::kMasterGain:
|
|
// Post-mixer gain: one atomic store; the audio thread picks it up next block.
|
|
processor_->setMasterGainLinear(masterGainLinearFromNorm(norm));
|
|
return;
|
|
default:
|
|
applyParamControl(id, norm, 0);
|
|
return;
|
|
}
|
|
}
|
|
|
|
double ReaSamplerEditor::deckPlainValue(int id) const {
|
|
const auto deck = static_cast<instrument::ui::DeckParam>(id);
|
|
// A curve exponent is read off its stored field, never round-tripped through the knob law:
|
|
// that law's centre detent snaps anything near-neutral back to exactly 1.0, so a round trip
|
|
// would misreport a stored exponent that isn't neutral as 1.00. The host has only the norm
|
|
// and therefore cannot make this distinction — param/CLAUDE.md records the divergence.
|
|
if (instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent) {
|
|
return curveExponentFor(id, params_.play);
|
|
}
|
|
return instrument::param::toPlain(deck, deckControlNorm(id));
|
|
}
|
|
|
|
std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
|
if (id < 0 || id >= static_cast<int>(ParamControl::kCount)) return {};
|
|
const auto deck = static_cast<instrument::ui::DeckParam>(id);
|
|
// The one deck knob with no plain-value layer at all: an already-integer count.
|
|
if (deck == ParamControl::kVoiceCount) {
|
|
char buf[24];
|
|
snprintf(buf, sizeof(buf), "%d", voiceCount_);
|
|
return std::string(buf);
|
|
}
|
|
if (instrument::param::paramIdFor(deck) == 0) return {};
|
|
|
|
// The digits come from the ONE formatter; everything the editor adds around them is static
|
|
// chrome — a constant prefix or suffix cannot diverge from what the host shows.
|
|
const double plain = deckPlainValue(id);
|
|
char digits[24];
|
|
instrument::param::formatPlainFor(deck, plain, digits, sizeof(digits));
|
|
const auto kind = instrument::param::unitKindFor(deck);
|
|
const char* caret =
|
|
instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent ? "^" : "";
|
|
// The gain at true silence reads "-inf", not "-infdB": there is no decibel value there.
|
|
if (kind == instrument::param::UnitKind::Decibels && !std::isfinite(plain)) {
|
|
return std::string(digits);
|
|
}
|
|
// The stage times are the one category that carries a space before its unit, and always did —
|
|
// this surface's own typography, not the host's (ParameterInfo::units is the bare string).
|
|
const char* gap = kind == instrument::param::UnitKind::Time ? " " : "";
|
|
return caret + std::string(digits) + gap + instrument::param::unitStringFor(deck);
|
|
}
|
|
|
|
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
|
// Lives here, beside controlValue/applyControl, because it must MATCH them: a node drag can
|
|
// never produce a param a knob couldn't. Every stage time caps at kEnvTimeMaxSeconds; the
|
|
// Hold fractions and the sustain level are [0,1] by definition and need no bound here.
|
|
EnvClampBounds b;
|
|
b.maxAttackSeconds = kEnvTimeMaxSeconds;
|
|
b.maxHoldSeconds = kEnvTimeMaxSeconds;
|
|
b.maxDecaySeconds = kEnvTimeMaxSeconds;
|
|
b.maxReleaseSeconds = kEnvTimeMaxSeconds;
|
|
return b;
|
|
}
|
|
|
|
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
|
|
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
|
|
// keyTrack sits beside the play bundle, so it takes deck_values' own pair rather than
|
|
// the PlaySeconds binding.
|
|
params_.keyTrack = instrument::ui::keyTrackFromNorm(value);
|
|
} else {
|
|
applyControl(id, params_.play, value, segment);
|
|
}
|
|
}
|
|
|
|
} // namespace reasampler::vst
|