// 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 knob-deck group // descriptors + control-id<->value binding, and the envelope pack/unpack (the trigger-seam // converter). Value logic only — no painting, no window plumbing. #include "shell/instrument/reasampler_editor.h" #include #include #include // snprintf (deck value labels) #include #include #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" #include "shell/instrument/reasampler_processor.h" namespace reasampler::vst { using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam converters using instrument::ui::EnvMode; // envelope_overlay's mode enum using instrument::ui::computeSampleBands; 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 { // Control-surface value domains (the shell owns these — param_slider is engine-free and maps // only 0..1). Wall-clock time sliders (AHDSR A/H/D/R, pitch env A/D) span [0, kEnvTimeMaxSeconds] // seconds — rate-free, exactly what the parameter set stores; the build resolves seconds->frames // at the live rate. Source-timeline fade sliders (Trigger fade-in/out) store source frames // (never a wall-clock second), but the knob's full-scale throw is a wall-clock intent — // kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames()) rather than a baked-in // rate constant, per the no-hardcoded-rate ruling. constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock) constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling (0..200%) } // 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 wrapped 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, w - 2 * kPad)); fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize); return fl; } double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const { // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over // the rate-resolved frames ceiling. Two domains, kept explicit so neither leaks a rate. A // stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01 // pins it) and gets rewritten down on the next knob touch. const double fadeMax = fadeMaxFrames(); const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; const auto framesToNorm = [fadeMax](std::int64_t f) { // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. return fadeMax > 0.0 ? clamp01(static_cast(f) / fadeMax) : 0.0; }; switch (static_cast(id)) { case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 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::kTrigLength: return clamp01(play.trigger.lengthFraction); case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames); case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames); case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0; case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds); case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds); 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; } } void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value, int segment) const { const double fadeMax = fadeMaxFrames(); // rate-resolved knob full-scale const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; const auto normToFrames = [fadeMax](double v) -> std::int64_t { // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. if (fadeMax <= 0.0) return 0; return static_cast(clamp01(v) * fadeMax + 0.5); }; switch (static_cast(id)) { case ParamControl::kPlayMode: play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; 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::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::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break; case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break; case ParamControl::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break; case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break; case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(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(clamp01(value)); break; case ParamControl::kFilterCutoff: play.filter.settings.cutoffNorm = static_cast(clamp01(value)); break; case ParamControl::kFilterQ: play.filter.settings.resonanceNorm = static_cast(clamp01(value)); break; case ParamControl::kFilterDrive: play.filter.settings.driveNorm = static_cast(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; } } double ReaSamplerEditor::liveSampleRate() const { return processor_ ? processor_->sampleRate() : 0.0; } double ReaSamplerEditor::fadeMaxFrames() const { // The Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) resolved // against the live rate — the same time base the envelope overlay already uses to place // these source-frame fades on screen. Pre-setupProcessing the rate is still 0: rather than // substitute a literal rate, callers treat a <= 0 return as "ceiling unavailable yet" and // degrade the knob to inert rather than guess a rate. Storage stays source frames — this // resolves the UI ceiling only. const double rate = liveSampleRate(); if (rate <= 0.0) return 0.0; return kFadeMaxSeconds * rate; } double ReaSamplerEditor::previewVelocity01() const { if (!processor_) return static_cast(kPreviewVelocityDefault) / 127.0; return static_cast(processor_->previewVelocity()) / 127.0; } double ReaSamplerEditor::deckControlNorm(int id) const { if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob switch (static_cast(id)) { case ParamControl::kKeyTrack: return clamp01(params_.keyTrack / kKeyTrackMax); 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); } } 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; } 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; } } std::string ReaSamplerEditor::deckValueLabel(int id) const { char buf[24]; buf[0] = '\0'; const PlaySeconds& play = params_.play; switch (id == -2 ? ParamControl::kCount : static_cast(id)) { case ParamControl::kAttack: snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break; case ParamControl::kHold: snprintf(buf, sizeof(buf), "%.3fs", play.adsr.holdSeconds); break; case ParamControl::kDecay: snprintf(buf, sizeof(buf), "%.3fs", play.adsr.decaySeconds); 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; case ParamControl::kTrigLength: snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break; case ParamControl::kTrigFadeIn: snprintf(buf, sizeof(buf), "%lldf", static_cast(play.trigger.fadeInFrames)); break; case ParamControl::kTrigFadeOut: snprintf(buf, sizeof(buf), "%lldf", static_cast(play.trigger.fadeOutFrames)); break; case ParamControl::kPitchEnvAttack: snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break; case ParamControl::kPitchEnvDecay: snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break; case ParamControl::kPitchEnvDepth: snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break; case ParamControl::kKeyTrack: snprintf(buf, sizeof(buf), "%.0f%%", params_.keyTrack * 100.0); break; case ParamControl::kVoiceCount: 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(filterQFromNorm(play.filter.settings.resonanceNorm))); break; case ParamControl::kFilterDrive: snprintf(buf, sizeof(buf), "%.2f", static_cast(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; } return std::string(buf); } EnvClampBounds ReaSamplerEditor::envClampBounds() const { // Match the control-panel sliders' own domains so a node drag can never produce a param a // slider couldn't. AHDSR seconds cap at kEnvTimeMaxSeconds; the Trigger fade/length // fractions cap at 1.0 (the natural full-span bound the sliders use). EnvClampBounds b; b.maxAttackSeconds = kEnvTimeMaxSeconds; b.maxHoldSeconds = kEnvTimeMaxSeconds; b.maxDecaySeconds = kEnvTimeMaxSeconds; b.maxReleaseSeconds = kEnvTimeMaxSeconds; b.maxFadeInFraction = 1.0; b.maxFadeOutFraction = 1.0; b.maxLengthFraction = 1.0; return b; } AmpEnvelope ReaSamplerEditor::packEnvelope(const PlaySeconds& play, std::int64_t frames, std::int64_t startFrame) const { AmpEnvelope env; env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate; // AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws). env.attackSeconds = play.adsr.attackSeconds; env.holdSeconds = play.adsr.holdSeconds; env.decaySeconds = play.adsr.decaySeconds; env.sustainLevel = play.adsr.sustainLevel; env.releaseSeconds = play.adsr.releaseSeconds; // Trigger: lengthFraction copies 1-to-1; the fades are derived — source frames over the played // span (the trigger-seam converter, pack direction). startFrame is the effective start // point so the fraction denominator matches the voice's actual post-start span. A zero play // length yields 0 fractions. env.lengthFraction = play.trigger.lengthFraction; const std::int64_t playLen = triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen); env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen); return env; } void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame, PlaySeconds& play) const { if (env.mode == EnvMode::Gate) { play.adsr.attackSeconds = env.attackSeconds; play.adsr.holdSeconds = env.holdSeconds; play.adsr.decaySeconds = env.decaySeconds; play.adsr.sustainLevel = env.sustainLevel; play.adsr.releaseSeconds = env.releaseSeconds; } else { // Trigger: lengthFraction copies back; the fades convert fractions -> source frames over // the played span (the trigger-seam converter, unpack direction). startFrame is the // effective start point so the frame denominator matches the voice's actual // post-start span. Keep the same (0,1] floor on lengthFraction the slider path enforces // so a zero-length trigger never plays nothing. play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction); const std::int64_t playLen = triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen); play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen); } } void ReaSamplerEditor::applyParamControl(int id, double value, int segment) { if (id == static_cast(ParamControl::kKeyTrack)) { // keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1. params_.keyTrack = clamp01(value) * kKeyTrackMax; } else { applyControl(id, params_.play, value, segment); } } } // namespace reasampler::vst