Merge dev into phase-psi: take Ξ-W3 before Ψ lands

# Conflicts:
#	docs/COMPLETED.md
#	docs/TODO.md
This commit is contained in:
2026-08-01 23:39:24 -04:00
46 changed files with 1931 additions and 313 deletions
+6 -3
View File
@@ -11,7 +11,7 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`deck_groups`, `deck_values`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
`core/wire` and is documented there — this directory consumes it but does not own it.
## Invariants
@@ -108,7 +108,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules, stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate.
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
@@ -122,7 +122,10 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
one.
- The bake's availability probe runs on the SAME tick that paints the button, so the
control can never be enabled on one tick and refuse on the next.
control can never be enabled on one tick and refuse on the next. The bake Hold control's
applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
because answering it costs a bridge read + bank parse whenever no loop override is set —
do not move either into the paint path.
- `editor_internal.h` is include-only — it has no TU of its own and must never become
a public seam; only the `reasampler_editor` band-axis TUs include it.
- **The editor window class carries `CS_DBLCLKS`, which REPLACES the second button-down of
+1
View File
@@ -89,6 +89,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
waveform_view bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
bake_hold
file_bytes curve_law stroke_aa
bake_plan bake_render bake_reset bake_wire wav_codec)
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
+14 -2
View File
@@ -15,6 +15,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/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 + its ms units
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
@@ -112,8 +113,13 @@ double ReaSamplerEditor::previewVelocity01() const {
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 clamp01(params_.keyTrack / kKeyTrackMax);
@@ -145,6 +151,11 @@ void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
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
@@ -171,7 +182,7 @@ 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<ParamControl>(id)) {
switch (id < 0 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
case ParamControl::kAttack:
formatEnvTimeMs(play.adsr.attackSeconds, buf, sizeof(buf)); break;
case ParamControl::kHold:
@@ -263,7 +274,8 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
snprintf(buf, sizeof(buf), "^%.2f", curveExponentFor(id, play));
break;
default:
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
// The chrome knobs (preview velocity, bake Hold) are labeled at their own call
// site; nothing else here.
break;
}
return std::string(buf);
+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)) {
@@ -48,6 +48,19 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
invalidate();
return true;
}
// Bake Hold: the same grab-anchored knob drag, and inert on exactly the ticks it is not
// drawn — the cell's width is reserved either way (sample_chrome.h says why).
if (bakeHoldNeeded_ && contains(cr.holdCell, x, y)) {
drag_ = DragKind::kDeckKnob;
dragParamId_ = kBakeHoldKnobId;
dragInnerCellId_ = -1;
dragKnobStartValue_ = bakeHoldNorm();
dragStartParams_ = params_;
dragStartX_ = x;
dragStartY_ = y;
invalidate();
return true;
}
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
// jumps the value; the delta from the grab point maps via knobDragValue.
if (contains(cr.velCell, x, y)) {
@@ -94,6 +107,18 @@ bool ReaSamplerEditor::doubleClickChrome(const FaceLayout& fl, int x, int y) {
// radial knob drawn by the same primitive, so it resolves through the same target rule
// (inKnobFace), against the drawn circle rather than the cell's label band.
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. 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;
}
if (!inKnobFace(fl.chrome.velKnob, x, y)) return false;
processor_->setPreviewVelocity(kPreviewVelocityDefault);
invalidate();
@@ -117,6 +142,7 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl
const ChromeRects& cr = fl.chrome;
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
if (bakeHoldNeeded_ && contains(cr.holdCell, x, y)) return {HoverKind::kHoldKnob, -1};
if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1};
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
+5 -6
View File
@@ -67,16 +67,15 @@ StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
// effectiveLengthFraction, not the stored knob: the overlay's amp/filter AHD must read the
// SAME span the engine plays, or its drawn shape and node drags cover only a fraction of
// what the note actually does.
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
triggerPlayLength(effectiveLengthFraction(play), frames, startFrame);
const double fullSpan =
rate > 0.0 ? static_cast<double>((std::max)(std::int64_t{0}, frames - startFrame)) / rate
: 0.0;
// Voice::start makes kTrigLength inert while any spline EG is active (trigSpan == postStart,
// not the %-length) — the overlay's amp/filter AHD must read the SAME span the engine plays,
// or its drawn shape and node drags cover only a fraction of what the note actually does.
const double playSpan =
splineActive(play) ? fullSpan : (rate > 0.0 ? static_cast<double>(playLen) / rate : 0.0);
const double playSpan = rate > 0.0 ? static_cast<double>(playLen) / rate : 0.0;
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
@@ -10,6 +10,7 @@
#include <cstdio>
#include <string>
#include "core/instrument/note/musical_division.h" // divisionLabel (the Hold readout)
#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / noteName
#include "core/instrument/ui/knob_deck.h" // kDeckKnobSize (the shared knob square)
#include "core/ui/tooltip.h" // computeTooltip (shared placement math)
@@ -145,6 +146,24 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
drawButton(bmp, box, "Bake", st, /*warn=*/false);
}
// Bake Hold: the note length the bake holds the gate for. Drawn ONLY while the window
// cannot be derived without it — every other dialed sound bakes with no user input, and a
// control that did nothing there would read as one that did.
if (bakeHoldNeeded_) {
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == kBakeHoldKnobId);
const bool hov = isHovered(HoverKind::kHoldKnob, -1);
const InteractionState st = dragging ? InteractionState::Dragging
: (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("Bake Hold");
kitTextCentered(bmp, cr.holdLabel, label.c_str(), Font::Micro, Role::TextDim);
}
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
// A drawn play triangle rather than a label or an embedded image: it inherits the button's
// own foreground role, so it stays legible in every interaction state at no build cost.
+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
+48
View File
@@ -17,6 +17,7 @@
#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key)
#include "core/util/file_bytes.h" // shared whole-file loader
#include "ext_keys.h"
#include "core/instrument/bake/bake_plan.h" // bakeWindowNeedsHold (the Hold predicate)
#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span)
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter)
#include "shell/instrument/instrument_bake.h" // the deferred bake the sync tick runs
@@ -56,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_) {
@@ -116,6 +118,13 @@ void ReaSamplerEditor::onSyncTimer() {
bakeAvailable_ = available;
invalidate();
}
// Same cadence for the Hold control's applicability, so it too can never paint live on
// one tick and be inert on the next.
const bool holdNeeded = resolveBakeHoldNeeded();
if (holdNeeded != bakeHoldNeeded_) {
bakeHoldNeeded_ = holdNeeded;
invalidate();
}
// The click armed it; this is where it runs — same thread, one tick later, no mouse
// capture held, and no REAPER action nested inside a mouse handler.
@@ -254,6 +263,45 @@ 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() {
// 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);
holdNeedAnswer_ = instrument::bake::bakeWindowNeedsHold(
PlayMode::Gate, SampleLoop{m.hasLoop, m.loopStart, m.loopEnd}, m.crossfade, frames);
return holdNeedAnswer_;
}
void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) {
// Write the edited markers into the parameter set as the loop/start override. The bank
// intrinsic is never written (read-only bank consumer).
+16 -6
View File
@@ -30,6 +30,7 @@ using instrument::bake::resetAfterBake;
using instrument::map::SampleRefEntry;
using instrument::map::SelectedSample;
using instrument::note::Tempo;
using instrument::note::Velocity;
using instrument::note::resolveNote;
using wire::BakeOutcome;
using wire::BakeRequest;
@@ -127,13 +128,22 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
std::optional<SampleData> snapshot = processor.bakeSnapshot();
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
const auto plan = planBake(
resolveNote(defaultBakeProgram(*snapshot, sampleRate, *tempo), *tempo), sampleRate,
rootNote);
if (!plan) return fail("the programmed capture window is empty");
// The note fires at the velocity the user has been auditioning at: three velocity curves
// 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, dialed.bakeHold, velocity),
*tempo),
sampleRate, rootNote);
if (!planned.plan) {
return fail(planned.refusal == instrument::bake::BakeRefusal::PastFrameCeiling
? "the dialed sound is longer than one bake can hold"
: "the programmed capture window is empty");
}
const instrument::bake::BakePlan& plan = *planned.plan;
const instrument::bake::BakeAudio audio =
renderBake(std::move(*snapshot), *plan, processor.masterGainLinear());
renderBake(std::move(*snapshot), plan, processor.masterGainLinear());
if (audio.empty()) return fail("the offline pass produced no audio");
// buildFloat32Wav takes doubles and narrows; the narrowing back to float is the bank's
@@ -164,7 +174,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
request.sourceRelativePath = source->relativePath;
request.sourceDisplayName = sourceEntry->displayName;
request.ownUsageKey = usageKeyFor(instanceGuid);
request.rootNote = plan->note;
request.rootNote = plan.note;
request.generation = stamp;
const std::string key = bakeKeyFor(instanceGuid);
+30
View File
@@ -99,6 +99,11 @@ private:
// the pure deck_groups module's — this alias keeps the shell's spelling.
using ParamControl = instrument::ui::DeckParam;
// The chrome knobs are drags of kind kDeckKnob with no place in the deck's id space, so
// they take negative sentinels (-2 is the preview velocity). Being outside
// [0, DeckParam::kCount) is what keeps liveCommitFor answering "not a live control".
static constexpr int kBakeHoldKnobId = -3;
// The waveform markers on the waveform band: start-point + the sustain loop's two ends,
// in draw + hit order, then the crossfade handle. The crossfade is NOT part of the
// full-height column hit-test — it answers only in its top-strip handle (waveform_view's
@@ -127,6 +132,7 @@ private:
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
kCurveNode, // a velocity-curve control point (index = point index)
kVelKnob, // the chrome preview-velocity radial knob
kHoldKnob, // the chrome bake-Hold radial knob
kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip
kPopupClose, // the curve popup's Close (x) button
};
@@ -334,6 +340,24 @@ private:
};
SetupMarkers pickedMarkers(std::int64_t frames) const;
// 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, 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);
@@ -378,6 +402,8 @@ private:
double liveSampleRate() const;
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
double previewVelocity01() const;
// The bake Hold division as a 0..1 knob position, via the pure bake_hold map.
double bakeHoldNorm() const;
// The normalized [0,1] value a deck knob shows — parameter-set ids route through
// controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
@@ -430,6 +456,10 @@ private:
// Whether the extension's bake action is registered, resolved on the same tick that
// governs the button's paint, so the control is never enabled and then refusing.
bool bakeAvailable_ = false;
// Whether the bake's window still needs the Hold control (bake_plan.h's
// bakeWindowNeedsHold). Resolved on the same tick as bakeAvailable_ rather than per paint:
// answering it costs a bridge read + bank parse whenever no loop override is set.
bool bakeHoldNeeded_ = false;
std::string bakeMessage_; // last outcome, shown in the title band
int bakeMessageTicks_ = 0; // sync ticks the message survives