Bake window: derived note lengths carry exact durations, not ladder rungs — a long take is no longer cut at 384 beats

Hold keeps its picker. Also: one home for the %-fold, duration-ordered Hold travel, and a corrupt tail degrades to absent rather than fabricating one.
This commit is contained in:
2026-08-01 21:10:38 -04:00
parent 19aeb92775
commit 65f6070348
35 changed files with 772 additions and 226 deletions
+7
View File
@@ -118,6 +118,13 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
invalidate();
return;
}
// A bare click on the Hold cell — no drag, no value change — would otherwise buy a bridge
// read, a WAV re-decode and an engine rebuild for a parameter the engine never reads.
if (kind == DragKind::kDeckKnob && paramId == kBakeHoldKnobId &&
params_.bakeHold == dragStartParams_.bakeHold) {
invalidate();
return;
}
// A live control already reached the voices during the drag; its release commits the
// final value through the same tier.
if (dragCommitsLive(kind, paramId)) {
+7 -3
View File
@@ -109,9 +109,13 @@ bool ReaSamplerEditor::doubleClickChrome(const FaceLayout& fl, int x, int y) {
if (selectedId_.empty() || !processor_) return false;
if (bakeHoldNeeded_ && inKnobFace(fl.chrome.holdKnob, x, y)) {
// The default is READ off a default-constructed parameter set, so there is no second
// table of defaults to drift from the codec's own lift.
params_.bakeHold = InstrumentParams{}.bakeHold;
commitAndReload();
// table of defaults to drift from the codec's own lift. A reset that changes nothing
// commits nothing — the same rule the release path applies.
const instrument::note::Division reset = InstrumentParams{}.bakeHold;
if (params_.bakeHold != reset) {
params_.bakeHold = reset;
commitAndReload();
}
invalidate();
return true;
}
+3 -1
View File
@@ -156,9 +156,11 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
: (hov ? InteractionState::Hover
: InteractionState::Rest);
drawKnobFace(bmp, cr.holdKnob, bakeHoldNorm(), st);
// "Bake Hold", not "Hold": the AMP deck's AHDSR Hold knob is on screen in the same
// frame, and two knobs labelled the same are two knobs the user has to guess between.
const std::string label = dragging || hov
? instrument::note::divisionLabel(params_.bakeHold)
: std::string("Hold");
: std::string("Bake Hold");
kitTextCentered(bmp, cr.holdLabel, label.c_str(), Font::Micro, Role::TextDim);
}
+5 -3
View File
@@ -24,9 +24,11 @@ constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
// The change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread
// timer: responsive enough that a recapture/ingest/assign refreshes within a bounded
// cadence, yet cheap — three small ext-state reads per tick, coalescing many bumps
// between ticks into one reload. 500 ms is a deliberate build-time residual. The id is a
// per-window SetTimer id (any nonzero).
// cadence, yet cheap — three small ext-state reads per tick in the steady state, coalescing
// many bumps between ticks into one reload. Anything costlier a tick answers (the bake-Hold
// predicate's bank parse) is memoized against its inputs, so keep it that way rather than
// letting a per-tick full read back in. 500 ms is a deliberate build-time residual. The id is
// a per-window SetTimer id (any nonzero).
constexpr UINT_PTR kSyncTimerId = 1;
constexpr UINT kSyncTimerIntervalMs = 500;
} // namespace
+33 -5
View File
@@ -57,6 +57,7 @@ void ReaSamplerEditor::refreshFromBank() {
// Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER).
thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks
pcmCache_.clear(); // and its decoded PCM (the waveform + snap source)
holdNeedValid_ = false; // …and the bake-Hold answer derived from the bank's loop intrinsic
channelPcmId_.clear();
channelPcm_ = ChannelPcm{};
if (!processor_) {
@@ -262,16 +263,43 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
return m;
}
bool ReaSamplerEditor::HoldNeedKey::operator==(const HoldNeedKey& o) const {
if (sampleId != o.sampleId || crossfade != o.crossfade) return false;
if (loopOverride.has_value() != o.loopOverride.has_value()) return false;
if (!loopOverride) return true;
return loopOverride->hasLoop == o.loopOverride->hasLoop &&
loopOverride->start == o.loopOverride->start &&
loopOverride->end == o.loopOverride->end;
}
bool ReaSamplerEditor::resolveBakeHoldNeeded() {
// The mode test comes first so the common Trigger case never pays pickedMarkers' bridge
// read. `m.hasLoop` alone is not the answer — the engine's fold also refuses a span that
// reaches outside the PCM, which is exactly what the pure predicate is asked for.
if (selectedId_.empty() || params_.play.playMode != PlayMode::Gate) return false;
// effectivePlayMode, not the raw field: the bake reads the mode AFTER the drawn-EG fold
// (resolvePlay, via bakeSnapshot), so a restored or foreign blob carrying Gate together
// with an active spline would otherwise paint a control for a sound that bakes as Trigger.
// The mode test comes first so the common Trigger case never pays the resolve below.
const PlayMode mode = effectivePlayMode(params_.play);
if (selectedId_.empty() || mode != PlayMode::Gate) return false;
// Everything past here costs a WAV decode and — with no loop override set — a bridge read
// plus a bank parse, and the sync tick asks twice a second for as long as an editor is
// open. Answer once per distinct input; refreshFromBank drops the memo along with the rest
// of the bank-derived caches, which is the same edge a bank generation bump would give.
// The mode is not part of the key: the resolve below always asks about Gate, and the test
// above is what keeps a Trigger sound from reaching it.
const HoldNeedKey key{selectedId_, params_.loopOverride, params_.loopCrossfadeFrames};
if (holdNeedValid_ && key == holdNeedKey_) return holdNeedAnswer_;
holdNeedValid_ = true;
holdNeedKey_ = key;
holdNeedAnswer_ = false;
const auto frames = static_cast<std::int64_t>(monoPcmFor(selectedId_).size());
if (frames <= 0) return false;
// `m.hasLoop` alone is not the answer — the engine's fold also refuses a span that reaches
// outside the PCM, which is exactly what the pure predicate is asked for.
const SetupMarkers m = pickedMarkers(frames);
return instrument::bake::bakeWindowNeedsHold(
holdNeedAnswer_ = instrument::bake::bakeWindowNeedsHold(
PlayMode::Gate, SampleLoop{m.hasLoop, m.loopStart, m.loopEnd}, m.crossfade, frames);
return holdNeedAnswer_;
}
void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) {
+1 -2
View File
@@ -132,8 +132,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
// are live, so a sound dialed at 127 does not bake as one dialed at 40.
const Velocity velocity = Velocity::of(processor.previewVelocity());
const instrument::bake::PlannedBake planned = planBake(
resolveNote(defaultBakeProgram(*snapshot, sampleRate, *tempo, dialed.bakeHold,
velocity),
resolveNote(defaultBakeProgram(*snapshot, sampleRate, dialed.bakeHold, velocity),
*tempo),
sampleRate, rootNote);
if (!planned.plan) {
+14 -1
View File
@@ -342,9 +342,22 @@ private:
// Whether the loaded sound's bake window needs the user's Hold — the pure predicate
// (bake_plan.h) answered against the markers this face is showing. Decodes and reads the
// bank, so it is called on the sync tick, not per paint.
// bank, so it is called on the sync tick, not per paint, and memoized against the inputs
// below on top of that.
bool resolveBakeHoldNeeded();
// What that answer was last computed against. Invalidated wholesale by refreshFromBank,
// which is where the bank half of the input changes.
struct HoldNeedKey {
std::string sampleId;
std::optional<SampleLoop> loopOverride;
std::int64_t crossfade = 0;
bool operator==(const HoldNeedKey& other) const;
};
HoldNeedKey holdNeedKey_;
bool holdNeedValid_ = false;
bool holdNeedAnswer_ = false;
// Writes `m` into params_ as the loop/start override. Does NOT call commitAndReload —
// callers decide live-drag vs final commit.
void applyMarkers(const SetupMarkers& m);