// 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 #include // isfinite (the gain's -inf label) #include #include // snprintf (deck value labels) #include #include #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(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(id), play); } void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value, int segment) const { setDeckParam(static_cast(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(ParamControl::kKeyTrack)) { params_.keyTrack = InstrumentParams{}.keyTrack; return; } resetDeckParam(static_cast(id), params_.play); } double ReaSamplerEditor::liveSampleRate() const { return processor_ ? processor_->sampleRate() : 0.0; } double ReaSamplerEditor::previewVelocity01() const { if (!processor_) return static_cast(kPreviewVelocityDefault) / 127.0; return static_cast(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(id)) { case ParamControl::kKeyTrack: return instrument::ui::keyTrackNormFrom(params_.keyTrack); case ParamControl::kVoiceCount: return clamp01(static_cast(voiceCount_ - kMinVoiceCount) / static_cast(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(WaveMark::kStart)] = m.start; w.frame[static_cast(WaveMark::kLoopStart)] = m.loopStart; w.frame[static_cast(WaveMark::kLoopEnd)] = m.loopEnd; // The crossfade grows LEFT from the seam it closes, which is where it is audible. w.frame[static_cast(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(WaveMark::kStart)] = true; w.present[static_cast(WaveMark::kLoopStart)] = gate; w.present[static_cast(WaveMark::kLoopEnd)] = gate; w.present[static_cast(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(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(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(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(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(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(ParamControl::kCount)) return {}; const auto deck = static_cast(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(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