Cut shell/instrument comment bloat ~34% (comments only, zero code change)
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
// editor_controls.cpp — the ReaSamplerEditor's PARAMETER PLUMBING (Q-W2v split of
|
||||
// reasampler_editor.cpp, T4-11): the control-value domain maps (controlValue /
|
||||
// applyControl — seconds/fraction/frames <-> normalized 0..1), the r11 knob-deck
|
||||
// group descriptors + control-id<->value binding, the S-VIEW-3 envelope pack/unpack
|
||||
// (the TRIGGER SEAM converter), the curve-popup target resolution, and applyZoneControl.
|
||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the control-value domain
|
||||
// maps (controlValue / applyControl — seconds/fraction/frames <-> normalized 0..1), the
|
||||
// knob-deck group descriptors + control-id<->value binding, the envelope pack/unpack
|
||||
// (the trigger-seam converter), the curve-popup target resolution, and applyZoneControl.
|
||||
// Value logic only — no painting, no window plumbing.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
@@ -13,8 +12,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1)
|
||||
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters (S-VIEW-3)
|
||||
#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/util/clamp01.h"
|
||||
#include "shell/instrument/editor_internal.h" // DeckGroup ids
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
@@ -22,35 +21,32 @@
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::instrument::map; // ZonePlaySeconds vocabulary + trigger_seam converters
|
||||
using instrument::ui::EnvMode; // envelope_overlay's mode enum (Q-W6: shim retired)
|
||||
using instrument::ui::EnvMode; // envelope_overlay's mode enum
|
||||
using instrument::engine::formatMasterGainLabel;
|
||||
using instrument::engine::masterGainLinearFromNorm;
|
||||
using instrument::engine::masterGainNormFromLinear;
|
||||
using util::clamp01;
|
||||
|
||||
namespace {
|
||||
// The S12/S15/S16 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 zone stores; the keymap build
|
||||
// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out)
|
||||
// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is
|
||||
// settled-correct and unchanged), but the knob's FULL-SCALE THROW is a wall-clock intent —
|
||||
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames(), Q-W0 T3-03; the
|
||||
// prior 88200-frame constant baked 2 s x 44.1 kHz into src/, against the no-hardcoded-rate
|
||||
// ruling). Build-time residual — one place to retune; not persisted.
|
||||
// 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 zone stores; the keymap 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; // S-VIEW-6 key-track slider ceiling (0..200%)
|
||||
constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling (0..200%)
|
||||
|
||||
} // namespace
|
||||
|
||||
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
|
||||
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
|
||||
// the rate-resolved frames ceiling (T3-03). 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 — deliberate, matching the old
|
||||
// fixed-ceiling clamp behavior in kind, just rate-dependent now instead of fixed at 88200.
|
||||
// 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) {
|
||||
@@ -80,7 +76,7 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const
|
||||
|
||||
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
|
||||
int segment) const {
|
||||
const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale
|
||||
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.
|
||||
@@ -122,14 +118,12 @@ double ReaSamplerEditor::liveSampleRate() const {
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::fadeMaxFrames() const {
|
||||
// T3-03: 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 (totalSeconds = frames / liveSampleRate()),
|
||||
// and the rate captures are made at (the capture path renders at the project rate).
|
||||
// Pre-setupProcessing the rate is still 0: rather than substitute a literal rate (the
|
||||
// exact residue T3-03 removed), bail the same way paintEnvelopeOverlay does (~line 1396) —
|
||||
// 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.
|
||||
// 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;
|
||||
@@ -141,11 +135,11 @@ double ReaSamplerEditor::previewVelocity01() const {
|
||||
}
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The PER-ZONE groups — the deck grammar both surfaces share (FB2: the Zone panel renders
|
||||
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs).
|
||||
// Group widths are MODE-INDEPENDENT: AMP ENVELOPE reserves its 5-cell Gate width (Trigger
|
||||
// leaves two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows
|
||||
// the neighbouring groups (r11).
|
||||
// The per-zone groups — the deck grammar both surfaces share (the Zone panel renders
|
||||
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs). Group
|
||||
// widths are mode-independent: AMP ENVELOPE reserves its 5-cell Gate width (Trigger leaves
|
||||
// two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows the
|
||||
// neighbouring groups.
|
||||
std::vector<DeckGroupDesc> out;
|
||||
{
|
||||
DeckGroupDesc amp;
|
||||
@@ -159,8 +153,8 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySe
|
||||
static_cast<int>(ParamControl::kSustain),
|
||||
static_cast<int>(ParamControl::kRelease)};
|
||||
} else {
|
||||
// Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out —
|
||||
// matches the drawn envelope), plus the two reserved blanks.
|
||||
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
|
||||
// the drawn envelope), plus the two reserved blanks.
|
||||
amp.cellIds = {static_cast<int>(ParamControl::kTrigFadeIn),
|
||||
static_cast<int>(ParamControl::kTrigLength),
|
||||
static_cast<int>(ParamControl::kTrigFadeOut), -1, -1};
|
||||
@@ -190,9 +184,8 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySe
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER
|
||||
// groups. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the
|
||||
// post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays
|
||||
// OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs.
|
||||
// groups. Per-instance state (ComponentState) stays off the Zone panel, so they are
|
||||
// appended here, not in zoneDeckGroupDescs.
|
||||
std::vector<DeckGroupDesc> out = zoneDeckGroupDescs(play);
|
||||
{
|
||||
DeckGroupDesc voice;
|
||||
@@ -303,8 +296,8 @@ std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone
|
||||
|
||||
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||
// Match the control-panel sliders' own domains so a node drag can never produce a param a
|
||||
// slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the
|
||||
// Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use).
|
||||
// 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;
|
||||
@@ -326,8 +319,8 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int
|
||||
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 zone's effective start
|
||||
// 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 zone's 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;
|
||||
@@ -348,10 +341,10 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
|
||||
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 zone's
|
||||
// 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.
|
||||
// the played span (the trigger-seam converter, unpack direction). startFrame is the
|
||||
// zone's 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);
|
||||
@@ -361,8 +354,8 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
|
||||
}
|
||||
|
||||
PerformanceZone ReaSamplerEditor::popupZone() const {
|
||||
// The zone the popup displays: the Zone surface's SELECTED zone (FB2), else the Sample
|
||||
// face's one-zone site (a read-only resolve — an edit materializes via popupZoneIndex).
|
||||
// The zone the popup displays: the Zone surface's selected zone, else the Sample face's
|
||||
// one-zone site (a read-only resolve — an edit materializes via popupZoneIndex).
|
||||
if (view_ == View::kZone && selectedZone_ >= 0 &&
|
||||
selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
return map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// editor_input_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE
|
||||
// input + the hover resolver (Q-W2v split of reasampler_editor.cpp, T4-11): the L3 hover
|
||||
// resolution across all three faces, the Browse picker's click branch (tabs, cards,
|
||||
// select-then-confirm, scroll-thumb grab, search focus), the Zone surface's click branch
|
||||
// (add/delete, strip drags, numeric-entry focus, per-zone deck + curve button), the
|
||||
// browser wheel scroll, the type-to-filter / note-entry keystrokes, and the S13 degraded
|
||||
// drop affordance. Windows-only (D5).
|
||||
// editor_input_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface
|
||||
// input + the hover resolver: hover resolution across all three faces, the Browse picker's
|
||||
// click branch (tabs, cards, select-then-confirm, scroll-thumb grab, search focus), the
|
||||
// Zone surface's click branch (add/delete, strip drags, numeric-entry focus, per-zone deck
|
||||
// + curve button), the browser wheel scroll, the type-to-filter / note-entry keystrokes,
|
||||
// and the degraded drop affordance. Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -15,10 +14,10 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12)
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup (popup hover)
|
||||
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
|
||||
#include "core/instrument/map/note_entry.h" // parseNoteEntry (S12 numeric entry)
|
||||
#include "core/instrument/map/note_entry.h" // parseNoteEntry (numeric entry)
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect (popup node hover)
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -28,8 +27,6 @@ using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
using namespace reasampler::instrument::map;
|
||||
|
||||
// --- Hover resolution (Phase L, L3) ------------------------------------------
|
||||
//
|
||||
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
|
||||
// idle move is free). Mirrors onMouseDown's hit-test order, but read-only. Windows-only.
|
||||
void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
@@ -57,7 +54,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
if (tab >= 0) h = {HoverKind::kFilterTab, tab};
|
||||
else if (card >= 0) h = {HoverKind::kCard, card};
|
||||
}
|
||||
} else if (curvePopupOpen_) { // the r11 curve popup — modal over Sample AND Zone (FB2)
|
||||
} else if (curvePopupOpen_) { // the curve popup — modal over Sample and Zone
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, hgt);
|
||||
if (contains(pl.close, x, y)) {
|
||||
h = {HoverKind::kPopupClose, -1};
|
||||
@@ -79,8 +76,8 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
|
||||
h = {HoverKind::kDeleteZone, -1};
|
||||
} else if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
// FB2: the per-zone knob deck + the mini curve-preview button (the Sample deck's
|
||||
// hover grammar — knobs light + swap label->value).
|
||||
// The per-zone knob deck + the mini curve-preview button (the Sample deck's hover
|
||||
// grammar — knobs light + swap label->value).
|
||||
if (contains(zonesCurveButton(content), x, y)) {
|
||||
h = {HoverKind::kCurveButton, -1};
|
||||
} else {
|
||||
@@ -93,7 +90,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
|
||||
}
|
||||
}
|
||||
} else { // Sample view (home, r11 recomposition)
|
||||
} else { // Sample view (home)
|
||||
const PerformanceZone zone = effectiveSampleZone();
|
||||
const std::vector<DeckGroupDesc> descs = deckGroupDescs(zone.play);
|
||||
const SampleBands bands =
|
||||
@@ -128,8 +125,8 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
}
|
||||
}
|
||||
|
||||
// The Browse-modal branch of the mouse-down dispatch (formerly inline in onMouseDown —
|
||||
// behavior-identical; see editor_input_sample.cpp for the dispatch).
|
||||
// The Browse-modal branch of the mouse-down dispatch (see editor_input_sample.cpp for the
|
||||
// dispatch).
|
||||
void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) {
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) {
|
||||
@@ -198,8 +195,8 @@ void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The Zone-surface branch of the mouse-down dispatch (formerly the tail of onMouseDown —
|
||||
// behavior-identical; the curve popup is modal over the Zone surface too, FB2).
|
||||
// The Zone-surface branch of the mouse-down dispatch (the curve popup is modal over the
|
||||
// Zone surface too).
|
||||
void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||||
const Rect back = zoneBackRect(w, h);
|
||||
@@ -209,11 +206,11 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
if (contains(addR, x, y)) {
|
||||
// Add a narrow default zone for the picked capture (or the first visible sample as a
|
||||
// sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id
|
||||
// already exists (pre-fix bleed survivor), select it rather than appending a duplicate
|
||||
// (mirrors the upsert the root-marker drag path already performs).
|
||||
// NARROW DEFAULT: seed [root-6, root+5] (one octave centred on the bank root, clamped
|
||||
// to [0,127]) so the new zone is immediately "authored" (narrow) and survives
|
||||
// reconcileSingleCaptureZones without being treated as a Sample-face full-range zone.
|
||||
// already exists, select it rather than appending a duplicate (mirrors the upsert the
|
||||
// root-marker drag path already performs). Narrow default: seed [root-6, root+5] (one
|
||||
// octave centred on the bank root, clamped to [0,127]) so the new zone is immediately
|
||||
// "authored" (narrow) and survives reconcileSingleCaptureZones without being treated
|
||||
// as a Sample-face full-range zone.
|
||||
std::string seed = !selectedId_.empty() ? selectedId_
|
||||
: (!visible_.empty() ? visible_.front().id : std::string());
|
||||
if (seed.empty()) return;
|
||||
@@ -290,7 +287,7 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
|
||||
// Numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
|
||||
// zone is selected. entryText_ starts empty (the user types the full value).
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const Rect fields = noteEntryFieldsArea(content);
|
||||
@@ -305,9 +302,9 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
}
|
||||
entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry
|
||||
|
||||
// The per-zone param surface (FB2): the knob deck + the mini curve-preview button — the
|
||||
// SAME grammar and hit-test machinery as the Sample face. Only when a zone is selected
|
||||
// (the Zone surface has no single-capture fallback — that lives on the Sample face).
|
||||
// The per-zone param surface: the knob deck + the mini curve-preview button — the same
|
||||
// grammar and hit-test machinery as the Sample face. Only when a zone is selected (the
|
||||
// Zone surface has no single-capture fallback — that lives on the Sample face).
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
if (contains(zonesCurveButton(content), x, y)) {
|
||||
curvePopupOpen_ = true;
|
||||
@@ -335,7 +332,7 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
||||
if (pitchEnvKnob && !play.pitchEnv.enabled) return;
|
||||
// GRAB-ANCHORED vertical drag (FA4): live-drag the map, commit on release.
|
||||
// Grab-anchored vertical drag: live-drag the map, commit on release.
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragParamZone_ = selectedZone_;
|
||||
@@ -351,8 +348,8 @@ void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
|
||||
void ReaSamplerEditor::onMouseWheel(int delta) {
|
||||
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
|
||||
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive
|
||||
// delta (wheel up) scrolls toward the top (smaller offset).
|
||||
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A
|
||||
// positive delta (wheel up) scrolls toward the top (smaller offset).
|
||||
if (view_ != View::kBrowse) return;
|
||||
const int rows = delta / 120;
|
||||
if (rows == 0) return;
|
||||
@@ -362,8 +359,8 @@ void ReaSamplerEditor::onMouseWheel(int delta) {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
// r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face
|
||||
// or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search
|
||||
// The curve popup: Esc dismisses (checked first — the popup is modal over the Sample face
|
||||
// or the Zone surface; opening it clears any note-entry focus, and the Browse search
|
||||
// cannot hold focus under it).
|
||||
if (curvePopupOpen_ && ch == 27) {
|
||||
curvePopupOpen_ = false;
|
||||
@@ -371,7 +368,7 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// S12 numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes
|
||||
// Numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes
|
||||
// and commits via parseNoteEntry on Enter. Handled before the search box (a field, when
|
||||
// focused, owns the keystrokes).
|
||||
if (view_ == View::kZone && entryField_ >= 0) {
|
||||
@@ -402,8 +399,9 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
|
||||
// deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search).
|
||||
// Type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
|
||||
// deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then
|
||||
// search).
|
||||
if (view_ != View::kBrowse || !searchFocused_) return;
|
||||
if (ch == 8) { // backspace
|
||||
if (!searchQuery_.empty()) searchQuery_.pop_back();
|
||||
@@ -421,12 +419,12 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
|
||||
// S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact
|
||||
// ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff
|
||||
// decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a
|
||||
// timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing
|
||||
// at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs
|
||||
// each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0.
|
||||
// The instrument is a read-only bank consumer and the cross-artifact ingest relay (editor
|
||||
// drop -> extension) is not shipped, so we do not ingest the dropped files and — load-
|
||||
// bearing — never insert a timeline item. Instead of silently swallowing the drop, flash a
|
||||
// clear affordance pointing at the shipped ingest gesture. dropHintTicks_ counts sync ticks
|
||||
// (kSyncTimerIntervalMs each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer
|
||||
// decays it to 0.
|
||||
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
|
||||
dropHintTicks_ = 6;
|
||||
#ifdef _WIN32
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// editor_input_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE input + the drag-state
|
||||
// machine (Q-W2v split of reasampler_editor.cpp, T4-11): the mouse-down dispatch (the
|
||||
// Sample-face branch inline; Browse/Zone branches delegate to editor_input_browse_zone),
|
||||
// the curve-popup/curve-box click machinery, the live drag resolution (onMouseMove — deck
|
||||
// knobs, root marker, envelope nodes, curve nodes, wave markers, scroll thumb, zone
|
||||
// edges), the release commit (onMouseUp), and the popup right-click delete. Windows-only
|
||||
// (D5). All hit-test math is pure; this TU routes and mutates editor state only.
|
||||
// editor_input_sample.cpp — the ReaSamplerEditor's sample-face input + the drag-state
|
||||
// machine: the mouse-down dispatch (the Sample-face branch inline; Browse/Zone branches
|
||||
// delegate to editor_input_browse_zone), the curve-popup/curve-box click machinery, the
|
||||
// live drag resolution (onMouseMove — deck knobs, root marker, envelope nodes, curve
|
||||
// nodes, wave markers, scroll thumb, zone edges), the release commit (onMouseUp), and the
|
||||
// popup right-click delete. Windows-only. All hit-test math is pure; this TU routes and
|
||||
// mutates editor state only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + thumbDragToOffset (scroll drag)
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet (r11)
|
||||
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag (S-VIEW-3)
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet
|
||||
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
|
||||
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
|
||||
#include "core/instrument/ui/param_slider.h" // knobDragValue (FA4 grab-anchored drag)
|
||||
#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap (S11)
|
||||
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
|
||||
#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -31,11 +31,10 @@ using namespace reasampler::instrument::ui;
|
||||
using namespace reasampler::instrument::map;
|
||||
|
||||
bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
|
||||
// The r11 curve popup: while open the sheet is MODAL over its host face — the Sample home
|
||||
// (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash
|
||||
// click dismiss (outside only when no drag is in flight, per the spec); in-box clicks
|
||||
// route to the shared curve machinery against popupZoneIndex(); anything else on the
|
||||
// sheet is swallowed.
|
||||
// The curve popup: while open the sheet is modal over its host face — the Sample home or
|
||||
// the Zone surface — it owns every left-click. Close click / outside-wash click dismiss
|
||||
// (outside only when no drag is in flight); in-box clicks route to the shared curve
|
||||
// machinery against popupZoneIndex(); anything else on the sheet is swallowed.
|
||||
if (!curvePopupOpen_) return false;
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
if (contains(pl.close, x, y)) {
|
||||
@@ -77,13 +76,10 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x,
|
||||
// ADD (mirror of the other map-editing drags' dragStartMap_ contract).
|
||||
dragStartMap_ = map_;
|
||||
|
||||
// Empty-space click inside the MAPPING BOX: add a control point at the cursor via the pure
|
||||
// inverse map, then grab it — the click flows straight into a placing drag. Guard: the caller
|
||||
// gates on contains(r, x, y) (the full border rect), but the 6+px inset ring — including the
|
||||
// caption band — must not add a point; a click there would clamp to velocity 0/127 and
|
||||
// produce an undeletable duplicate stacked on an endpoint. Clicks in the ring may still grab
|
||||
// an existing node (pointAtPixel's pick radius legitimately extends into the ring), which is
|
||||
// handled above; only the add path is box-gated here.
|
||||
// Empty-space click inside the mapping box: add a control point via the pure inverse map,
|
||||
// then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a
|
||||
// point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an endpoint.
|
||||
// A ring click can still grab an existing node (handled above); only add is box-gated.
|
||||
if (idx < 0) {
|
||||
const bool inBox = (x >= box.left && x < box.left + box.width &&
|
||||
y >= box.top && y < box.top + box.height);
|
||||
@@ -115,15 +111,15 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
|
||||
// ---- Browse modal (S-VIEW-5): the face branch lives in editor_input_browse_zone ----
|
||||
// Browse modal: the face branch lives in editor_input_browse_zone.
|
||||
if (view_ == View::kBrowse) {
|
||||
mouseDownBrowse(w, h, x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Sample home (S-VIEW-2 / r11) ----
|
||||
// Sample home.
|
||||
if (view_ == View::kSample) {
|
||||
// r11 curve popup: while open the sheet is modal — it owns every left-click.
|
||||
// The curve popup: while open the sheet is modal — it owns every left-click.
|
||||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||||
|
||||
const PerformanceZone probeZone = effectiveSampleZone();
|
||||
@@ -155,8 +151,8 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Radial preview-velocity knob (r11): GRAB-ANCHORED vertical drag — the grab itself
|
||||
// never jumps the value (FA4); the delta from the grab point maps via knobDragValue.
|
||||
// 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)) {
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param)
|
||||
@@ -187,9 +183,9 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The knob deck (r11): toggles commit at once (a discrete, final edit — the slider
|
||||
// precedent); knobs start a grab-anchored vertical drag. The deck band swallows its
|
||||
// clicks (no fall-through to the hero/markers).
|
||||
// The knob deck: toggles commit at once (a discrete, final edit); knobs start a
|
||||
// grab-anchored vertical drag. The deck band swallows its clicks (no fall-through to
|
||||
// the hero/markers).
|
||||
if (contains(bands.deck, x, y)) {
|
||||
const DeckLayout dl = layoutDeck(deckDescs, bands.deck.x, bands.deck.y,
|
||||
bands.deck.width);
|
||||
@@ -266,7 +262,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers.
|
||||
// Hero waveform: envelope nodes first, then the wave markers.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
const Rect waveArea = bands.hero;
|
||||
@@ -304,7 +300,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fenced root strip: grab the root marker (remainder-width since r11).
|
||||
// Fenced root strip: grab the root marker (remainder-width).
|
||||
if (cr.rootStrip.width > 0) {
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y);
|
||||
@@ -320,7 +316,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Zone surface (S-VIEW-8 / FB2): the face branch lives in editor_input_browse_zone ----
|
||||
// Zone surface: the face branch lives in editor_input_browse_zone.
|
||||
mouseDownZone(w, h, x, y);
|
||||
}
|
||||
|
||||
@@ -335,24 +331,24 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
const int dx = x - dragStartX_;
|
||||
|
||||
if (drag_ == DragKind::kDeckKnob) {
|
||||
// r11 radial knob: GRAB-ANCHORED vertical drag — knobDragValue maps the y delta from
|
||||
// the value at grab (up = increase), so the value tracks relative motion and never
|
||||
// jumps on grab (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP.
|
||||
// Radial knob: grab-anchored vertical drag — knobDragValue maps the y delta from the
|
||||
// value at grab (up = increase), so the value tracks relative motion and never jumps
|
||||
// on grab. Live feedback; zone-param commits land on WM_LBUTTONUP.
|
||||
const int dy = y - dragStartY_;
|
||||
applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy));
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// r11: the Sample bands derive from the deck height (mode-independent width math). Hoisted
|
||||
// The Sample bands derive from the deck height (mode-independent width math). Hoisted
|
||||
// below the kDeckKnob early-return — that branch uses neither deckDescs nor bands.
|
||||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(effectiveSampleZone().play);
|
||||
const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
|
||||
if (drag_ == DragKind::kRootMarker) {
|
||||
// The fenced root strip on the Sample cluster band. Setting the root materializes a
|
||||
// full-keyboard zone carrying the override on the picked id (the D-B override vehicle) —
|
||||
// upsert by id so a repeated drag edits the same zone rather than stacking duplicates.
|
||||
// full-keyboard zone carrying the override on the picked id — upsert by id so a
|
||||
// repeated drag edits the same zone rather than stacking duplicates.
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const Rect stripArea = clusterRects(bands.cluster, chan.mono, kDeckKnobSize).rootStrip;
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
@@ -381,10 +377,11 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kEnvNode) {
|
||||
// S-VIEW-3: resolve the grabbed envelope node's new params from the pixel delta (through
|
||||
// the pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the
|
||||
// picked id's one-zone play params. The AmpEnvelope was snapshotted at grab (dragStartEnv_)
|
||||
// so the delta is absolute. Materialize the zone if needed (mirror of the marker path).
|
||||
// Resolve the grabbed envelope node's new params from the pixel delta (through the
|
||||
// pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the
|
||||
// picked id's one-zone play params. The AmpEnvelope was snapshotted at grab
|
||||
// (dragStartEnv_) so the delta is absolute. Materialize the zone if needed (mirror of
|
||||
// the marker path).
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
const double rate = liveSampleRate();
|
||||
if (frames <= 0 || rate <= 0.0) return;
|
||||
@@ -403,9 +400,9 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kCurveNode) {
|
||||
// S-VIEW-10: resolve the grabbed control point from the pixel delta through the pure
|
||||
// inverse map (box + neighbour-X + endpoint-pin clamps), against the grab-time curve +
|
||||
// box (absolute delta — the mirror of the envelope-node drag). Live feedback only; the
|
||||
// Resolve the grabbed control point from the pixel delta through the pure inverse map
|
||||
// (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box
|
||||
// (absolute delta — the mirror of the envelope-node drag). Live feedback only; the
|
||||
// commit lands on WM_LBUTTONUP.
|
||||
if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||
if (curvePointIndex_ < 0) return;
|
||||
@@ -419,8 +416,8 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kWaveMarker) {
|
||||
// S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap
|
||||
// it against the decoded PCM, apply the inter-marker clamps, and write the override live.
|
||||
// Resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap it
|
||||
// against the decoded PCM, apply the inter-marker clamps, and write the override live.
|
||||
const Rect waveArea = bands.hero;
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
if (frames <= 0) return;
|
||||
@@ -431,8 +428,8 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
dragStartMarkers_.loopEnd};
|
||||
std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx);
|
||||
|
||||
// Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware
|
||||
// requirement). Pure over the cached mono frames — no host types, no file I/O.
|
||||
// Snap to the nearest zero crossing in the decoded PCM. Pure over the cached mono
|
||||
// frames — no host types, no file I/O.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
if (!pcm.empty()) {
|
||||
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
|
||||
@@ -464,8 +461,8 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kScrollThumb) {
|
||||
// S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag
|
||||
// only happens in the Browse modal (the sole card grid). The visible-card window recomputes
|
||||
// Map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag only
|
||||
// happens in the Browse modal (the sole card grid). The visible-card window recomputes
|
||||
// at paint from scrollOffset_.
|
||||
const int dyThumb = y - dragStartY_;
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
@@ -535,9 +532,9 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// S-VIEW-10 drag-off delete: releasing a curve-node drag well OUTSIDE the box removes the
|
||||
// dragged point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain
|
||||
// move — its amp keeps the last clamped drag value).
|
||||
// Drag-off delete: releasing a curve-node drag well outside the box removes the dragged
|
||||
// point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move —
|
||||
// its amp keeps the last clamped drag value).
|
||||
if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 &&
|
||||
curveZone < static_cast<int>(map_.zones.size())) {
|
||||
const bool off = x < curveRect.x - kCurveDragOffMargin ||
|
||||
@@ -554,12 +551,12 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseRDown(int x, int y) {
|
||||
// r11 (issue 3c): right-click on a popup curve node deletes it — the PRIMARY delete
|
||||
// affordance; Alt-click and drag-off remain as landed alternates. Commits immediately
|
||||
// through the same path as Alt-click; deletePoint's endpoint guard makes an endpoint
|
||||
// right-click a safe no-op. Right-clicks act ONLY while the popup is open — over the
|
||||
// Sample face OR the Zone surface (FB2; nothing else in the editor consumes them) —
|
||||
// and never during an in-flight left drag.
|
||||
// Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click
|
||||
// and drag-off remain as landed alternates. Commits immediately through the same path as
|
||||
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
|
||||
// Right-clicks act only while the popup is open — over the Sample face or the Zone
|
||||
// surface (nothing else in the editor consumes them) — and never during an in-flight left
|
||||
// drag.
|
||||
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
|
||||
if (drag_ != DragKind::kNone) return;
|
||||
RECT rc{};
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
// editor_internal.h — INTERNAL shared helpers for the ReaSamplerEditor TU family
|
||||
// (Q-W2v: the eight face-axis TUs split out of the former reasampler_editor.cpp).
|
||||
// Included ONLY by the editor's own shell TUs (editor_session / editor_controls /
|
||||
// editor_paint_* / editor_input_* / editor_platform) — never a public seam. Holds the
|
||||
// former god-TU's anonymous-namespace helpers that more than one split TU needs: the
|
||||
// Rect<->kit adapters, the small draw primitives (knob face / spectral strip / root
|
||||
// marker / title band), the label helpers, the deck group ids, and the velocity-curve
|
||||
// box derivation. All inline; behavior-identical to the pre-split definitions.
|
||||
// editor_internal.h — shared helpers for the ReaSamplerEditor 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. Holds the Rect<->kit adapters,
|
||||
// small draw primitives (knob face / spectral strip / root marker / title band), label
|
||||
// helpers, deck group ids, and the velocity-curve box derivation. All inline.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -24,7 +21,7 @@
|
||||
|
||||
#include "core/audio/peaks.h" // Envelope (drawEnvelope)
|
||||
#include "core/instrument/ui/capture_browser.h" // BrowserLayout / cardThumbnailRect (thumbBins)
|
||||
#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace, FA4)
|
||||
#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace)
|
||||
#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / isNaturalKey (spectral strip)
|
||||
#include "core/ui/component_geometry.h" // KitBox / waveformColumnCount
|
||||
#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor
|
||||
@@ -33,8 +30,7 @@
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck
|
||||
// order. Shared by the deck-desc builders (editor_controls) and the deck painter.
|
||||
// Deck group ids (shell-owned; knob_deck treats them opaquely), left-to-right order.
|
||||
enum DeckGroup {
|
||||
kGroupAmpEnv = 0,
|
||||
kGroupPitch,
|
||||
@@ -43,17 +39,14 @@ enum DeckGroup {
|
||||
kGroupMaster,
|
||||
};
|
||||
|
||||
// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the
|
||||
// curve in the POPUP (curve_popup), each summoned from its own mini preview button. The
|
||||
// INSET keeps node handles + the pick radius inside the border so an endpoint at amp 0/1
|
||||
// stays grabbable — the ONE curveBoxFromRect grammar the popup derives its mapping box
|
||||
// through. Drag-off: release beyond box+margin deletes the dragged node.
|
||||
// Velocity-curve editor box metrics. The inset keeps node handles + the pick radius
|
||||
// inside the border so an endpoint at amp 0/1 stays grabbable; drag-off beyond
|
||||
// box+margin deletes the dragged node.
|
||||
inline constexpr int kVelCurveInset = 14;
|
||||
inline constexpr int kCurveDragOffMargin = 24;
|
||||
|
||||
// The pure-module mapping Box for a drawn curve rect: inset from the border so node
|
||||
// handles and the pick radius stay inside the box. Every consumer (paint, hit-test, add,
|
||||
// drag) derives the Box through this ONE formula, so drawn nodes and grabs never drift.
|
||||
// The pure-module mapping Box for a drawn curve rect. Every consumer (paint, hit-test,
|
||||
// add, drag) derives it through this ONE formula, so drawn nodes and grabs never drift.
|
||||
inline instrument::engine::VelocityCurve::Box curveBoxFromRect(
|
||||
const instrument::ui::Rect& r) {
|
||||
return instrument::engine::VelocityCurve::Box{
|
||||
@@ -74,8 +67,8 @@ inline std::string noteLabel(int note) {
|
||||
}
|
||||
|
||||
// A display name for a bank sample id: the snapshotted bank list first, then the
|
||||
// instance-OWNED ref's displayName (pS — the label survives with the extension absent /
|
||||
// bank unreadable). "?" only when neither source knows the id.
|
||||
// instance-owned ref's displayName (survives with the extension absent). "?" if neither
|
||||
// source knows the id.
|
||||
inline std::string sampleLabel(const std::vector<instrument::map::SampleChoice>& samples,
|
||||
const instrument::map::SampleRefs& refs,
|
||||
const std::string& id) {
|
||||
@@ -90,11 +83,9 @@ inline std::string sampleLabel(const std::vector<instrument::map::SampleChoice>&
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
// --- Rect <-> kit adapters (Phase L, L3) -------------------------------------
|
||||
//
|
||||
// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against
|
||||
// `KitBox` (component_geometry). This is the single boundary that bridges them so every
|
||||
// draw routes through the L1 kit (theme roles + draw_kit).
|
||||
// draw routes through the shared kit (theme roles + draw_kit).
|
||||
inline ui::KitBox toKitBox(const instrument::ui::Rect& r) {
|
||||
return ui::KitBox{r.x, r.y, r.width, r.height};
|
||||
}
|
||||
@@ -110,23 +101,22 @@ inline void kitTextCentered(LICE_IBitmap* bmp, const instrument::ui::Rect& r,
|
||||
text(bmp, toKitBox(r), s, font, role, Align::Center);
|
||||
}
|
||||
|
||||
// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3).
|
||||
// Draw a peak envelope in `r` through the kit's shared waveform primitive.
|
||||
inline void drawEnvelope(LICE_IBitmap* bmp, const instrument::ui::Rect& r,
|
||||
const audio::Envelope& env) {
|
||||
drawWaveform(bmp, toKitBox(r), env);
|
||||
}
|
||||
|
||||
// The bin count a card's thumbnail is computed at: one bin per drawn pixel column — the
|
||||
// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins.
|
||||
// thumbnailFor clamps the request to the decoded frame count.
|
||||
// The bin count a card's thumbnail is computed at: one bin per drawn pixel column (the
|
||||
// gap-free render comes from peaks::columnMinMax's exact partition).
|
||||
inline int thumbBins(const instrument::ui::BrowserLayout& layout) {
|
||||
return (std::max)(1, kWaveformOversample *
|
||||
ui::waveformColumnCount(toKitBox(
|
||||
instrument::ui::cardThumbnailRect(layout, 0))));
|
||||
}
|
||||
|
||||
// Draw the title band with the live readout. Shared by the Sample face (nav visible) —
|
||||
// Browse/Zone draw their own back button in place of the nav.
|
||||
// Draws the title band with the live readout. Browse/Zone draw their own back button in
|
||||
// place of the nav.
|
||||
inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title,
|
||||
const std::string& readout) {
|
||||
fillSurface(bmp, toKitBox(title), ui::Role::BgPanel, ui::InteractionState::Rest);
|
||||
@@ -135,12 +125,10 @@ inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title,
|
||||
kitText(bmp, titleText, readout.c_str(), Font::Title, ui::Role::TextPrimary);
|
||||
}
|
||||
|
||||
// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle
|
||||
// map; this turns it into LICE calls through the kit's palette roles. LICE's arc
|
||||
// convention matches param_slider's (angle 0 = 12 o'clock, positive clockwise) — but LICE
|
||||
// takes RADIANS, and drawing the 7->5 o'clock sweep THROUGH the top needs a continuous
|
||||
// angle span, so the degrees convert as (deg - 360) * pi/180, mapping 210..510 onto
|
||||
// -150..+150 degrees. One conversion, both arcs.
|
||||
// Draws one radial knob face: param_slider owns the value<->angle map; this turns it into
|
||||
// LICE calls. LICE takes radians, and drawing the 7->5 o'clock sweep through the top needs
|
||||
// a continuous angle span, so degrees convert as (deg - 360) * pi/180, mapping 210..510
|
||||
// onto -150..+150 degrees.
|
||||
inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect,
|
||||
double value01, ui::InteractionState st) {
|
||||
using instrument::ui::KnobArc;
|
||||
@@ -149,14 +137,13 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
|
||||
const KnobGeometry kg = instrument::ui::computeKnob(knobRect);
|
||||
if (kg.radius <= 1.0) return;
|
||||
constexpr double kDegToRad = 3.14159265358979323846 / 180.0;
|
||||
const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep
|
||||
const KnobArc arc{}; // the default 7->5 o'clock sweep
|
||||
const float cx = static_cast<float>(kg.centerX);
|
||||
const float cy = static_cast<float>(kg.centerY);
|
||||
const float rOuter = static_cast<float>(kg.radius) - 0.5f;
|
||||
const bool disabled = (st == ui::InteractionState::Disabled);
|
||||
const bool hot = (st == ui::InteractionState::Dragging || st == ui::InteractionState::Hover);
|
||||
|
||||
// Face: a filled circle in the cell surface color under the interaction state.
|
||||
LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(ui::roleColorState(ui::Role::BgCell, st)),
|
||||
1.0f, 0, true);
|
||||
// Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare).
|
||||
@@ -165,8 +152,6 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
|
||||
(arc.startDeg + instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
|
||||
LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(ui::roleColor(ui::Role::LineHairline)), 1.0f, 0,
|
||||
true);
|
||||
// Value arc: start -> the value's angle, in the live accent (hot while under the pointer /
|
||||
// dragging, dim when disabled).
|
||||
const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01);
|
||||
if (v > 0.0) {
|
||||
const float av = static_cast<float>(
|
||||
@@ -185,11 +170,9 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
|
||||
toLice(ui::roleColor(needleRole)), 1.0f, 0, true);
|
||||
}
|
||||
|
||||
// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature
|
||||
// surface. Fills each MIDI key column with its spectral hue, then draws faint per-octave
|
||||
// hairline ticks. Shared by the setup face + the Zones strip so both read as the same
|
||||
// spectrum. S-VIEW-7: accidentals get a dark bg/base wash over the hue (an OVERLAY, not
|
||||
// a keyboard shape) so pitch position reads as a keyboard at a glance.
|
||||
// Draws the pastel spectral keyboard-strip background: each MIDI key column filled with
|
||||
// its spectral hue, accidentals darkened with an overlay wash so pitch position reads as
|
||||
// a keyboard at a glance. Shared by the setup face + the Zones strip.
|
||||
inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) {
|
||||
using instrument::ui::StripLayout;
|
||||
if (stripArea.width <= 0 || stripArea.height <= 0) return;
|
||||
@@ -218,8 +201,8 @@ inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& str
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the single-capture root marker on the strip: an accent-primary bar with a soft
|
||||
// STATIC glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark.
|
||||
// Draws the single-capture root marker: an accent-primary bar with a soft static glow —
|
||||
// the "this is live" mark.
|
||||
inline void drawRootMarker(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea,
|
||||
const instrument::ui::StripLayout& sl, int root) {
|
||||
const int sx = stripArea.x;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// editor_paint_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE
|
||||
// painting (Q-W2v split of reasampler_editor.cpp, T4-11): the full-window select-then-
|
||||
// confirm picker (S-VIEW-5 — wash, search box, filter tabs, card grid, scrollbar,
|
||||
// footer) and the Zone keymap surface (S-VIEW-8/FB2 — add/delete, the spectral zones
|
||||
// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only
|
||||
// (D5). Shares the Sample face's painters (title band / empty state / deck / curve
|
||||
// button / popup) via the class + editor_internal.h.
|
||||
// editor_paint_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface
|
||||
// painting: the full-window select-then-confirm picker (wash, search box, filter tabs, card
|
||||
// grid, scrollbar, footer) and the Zone keymap surface (add/delete, the spectral zones
|
||||
// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only.
|
||||
// Shares the Sample face's painters (title band / empty state / deck / curve button /
|
||||
// popup) via the class + editor_internal.h.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -15,8 +14,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12)
|
||||
#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout (FB2)
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
|
||||
#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + labels
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -27,8 +26,8 @@ using namespace reasampler::instrument::ui; // browser/strip/deck/zone-surface
|
||||
using namespace reasampler::instrument::map; // SampleChoice / BankChoice / SampleRefs
|
||||
|
||||
void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
|
||||
// A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying
|
||||
// Sample face with a bg/base wash, then draw the picker opaque on top.
|
||||
// A full-window modal sheet over the Sample face. Dim the underlying Sample face with a
|
||||
// bg/base wash, then draw the picker opaque on top.
|
||||
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0);
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
|
||||
@@ -84,8 +83,9 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
|
||||
active ? Role::BgBase : Role::TextPrimary);
|
||||
}
|
||||
|
||||
// Cards (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_)
|
||||
// is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border.
|
||||
// Cards (the visible window at the current scroll offset). The pending pick
|
||||
// (browsePendingId_) is marked with the accent-primary border; the currently-loaded id
|
||||
// gets a faint tertiary border.
|
||||
const int bins = thumbBins(bl);
|
||||
const int cardCount = static_cast<int>(visible_.size());
|
||||
const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_);
|
||||
@@ -192,8 +192,8 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||||
drawButton(bmp, box, "Delete", state, /*warn=*/false);
|
||||
}
|
||||
|
||||
// The zones strip — the same PASTEL SPECTRAL surface as the Sample face, with one bar per
|
||||
// zone over the spectrum. The SELECTED zone lifts to accent-primary + a static glow ("which
|
||||
// The zones strip — the same pastel spectral surface as the Sample face, with one bar per
|
||||
// zone over the spectrum. The selected zone lifts to accent-primary + a static glow ("which
|
||||
// zone is live"); the rest take the categorical secondary hue at low alpha.
|
||||
const Rect stripArea = zonesStripArea(content);
|
||||
drawSpectralStrip(bmp, stripArea);
|
||||
@@ -218,8 +218,8 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||||
}
|
||||
|
||||
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
|
||||
// entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it
|
||||
// (entryField_) and typed text commits via parseNoteEntry on Enter.
|
||||
// entry fields (low / high / root). Clicking a field focuses it (entryField_) and typed
|
||||
// text commits via parseNoteEntry on Enter.
|
||||
const int legendTop = stripArea.bottom() + 8;
|
||||
Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18);
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
@@ -256,19 +256,18 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||||
Font::Label, Role::TextDim);
|
||||
}
|
||||
|
||||
// The per-zone parameter surface for the selected zone. FB2 (R11-F2): the SAME knob deck +
|
||||
// curve-preview-button/popup grammar as the Sample face — one control language over the one
|
||||
// storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. Only
|
||||
// the per-zone groups render here; VOICE/MASTER are per-instance (ComponentState) and live
|
||||
// on the Sample deck only.
|
||||
// The per-zone parameter surface for the selected zone: the same knob deck +
|
||||
// curve-preview-button/popup grammar as the Sample face — one control language over the
|
||||
// one storage site. Only the per-zone groups render here; VOICE/MASTER are per-instance
|
||||
// (ComponentState) and live on the Sample deck only.
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play));
|
||||
paintCurveButton(bmp, zonesCurveButton(content), z);
|
||||
}
|
||||
|
||||
// The curve popup (FB2): a centered sheet over the whole Zone surface, drawn LAST —
|
||||
// the same modal grammar as the Sample face.
|
||||
// The curve popup: a centered sheet over the whole Zone surface, drawn last — the same
|
||||
// modal grammar as the Sample face.
|
||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// editor_paint_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE painting (Q-W2v split of
|
||||
// reasampler_editor.cpp, T4-11): the WM_PAINT dispatch, the r11 Sample home face (title
|
||||
// band + elastic hero waveform + root/preview cluster + bottom-anchored knob deck), the
|
||||
// S-VIEW-3 envelope overlay, the velocity-curve editor + mini preview button + popup
|
||||
// sheet (shared painters the Zone surface reuses, FB2), and the empty state. Windows-only
|
||||
// (D5); draws through the L1 kit by palette role. All layout math is pure
|
||||
// (editor_geometry / knob_deck / curve_popup) — this TU only draws.
|
||||
// editor_paint_sample.cpp — the ReaSamplerEditor's sample-face painting: the WM_PAINT
|
||||
// dispatch, the Sample home face (title band + elastic hero waveform + root/preview cluster
|
||||
// + bottom-anchored knob deck), the envelope overlay, the velocity-curve editor + mini
|
||||
// preview button + popup sheet (shared painters the Zone surface reuses), and the empty
|
||||
// state. Windows-only; draws through the shared kit by palette role. All layout math is
|
||||
// pure (editor_geometry / knob_deck / curve_popup) — this TU only draws.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -17,10 +16,10 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // computeEnvelope (hero waveform binning)
|
||||
#include "core/instrument/ui/curve_popup.h" // r11 centered curve-popup sheet geometry (FB1)
|
||||
#include "core/instrument/ui/curve_popup.h" // centered curve-popup sheet geometry
|
||||
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
|
||||
#include "core/instrument/ui/waveform_view.h" // frameToX (S11 markers)
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived title band, S18)
|
||||
#include "core/instrument/ui/waveform_view.h" // frameToX (waveform markers)
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived title band)
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + knob face/spectral strip/root marker
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -32,8 +31,8 @@ using namespace reasampler::instrument::map; // SampleRefs / findRef (title read
|
||||
using audio::computeEnvelope;
|
||||
|
||||
namespace {
|
||||
// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette: start = teal
|
||||
// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple.
|
||||
// Marker roles — semantic, drawn through the kit's palette: start = teal (secondary), loop
|
||||
// start/end = purple (tertiary). The loop-span fill is a faint purple.
|
||||
constexpr Role kRoleStartMarker = Role::AccentSecondary;
|
||||
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
|
||||
} // namespace
|
||||
@@ -48,9 +47,9 @@ void ReaSamplerEditor::paint(HDC hdc) {
|
||||
LICE_SysBitmap bmp(w, h);
|
||||
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
|
||||
|
||||
// S-VIEW-1 three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn
|
||||
// OVER Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the
|
||||
// modal reads as a sheet layered over the home face (the "picker over the document" grammar).
|
||||
// Three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn over
|
||||
// Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the
|
||||
// modal reads as a sheet layered over the home face.
|
||||
if (view_ == View::kZone) {
|
||||
paintZone(&bmp, w, h);
|
||||
} else {
|
||||
@@ -58,9 +57,9 @@ void ReaSamplerEditor::paint(HDC hdc) {
|
||||
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
|
||||
}
|
||||
|
||||
// S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window.
|
||||
// It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn
|
||||
// LAST so it overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||||
// A transient banner flashed after a file was dropped on this window. It reiterates the
|
||||
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
|
||||
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||||
if (dropHintTicks_ > 0) {
|
||||
const int bannerTop = (std::min)(kTitleHeight, h);
|
||||
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
|
||||
@@ -77,21 +76,20 @@ void ReaSamplerEditor::paint(HDC hdc) {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
// r11: the deck height comes from the pure knob_deck wrap (mode-independent — the AMP
|
||||
// ENVELOPE group reserves its 5-cell Gate width, so Gate<->Trigger never changes it).
|
||||
// The deck height comes from the pure knob_deck wrap (mode-independent — the AMP ENVELOPE
|
||||
// group reserves its 5-cell Gate width, so Gate<->Trigger never changes it).
|
||||
const PerformanceZone deckZone = effectiveSampleZone();
|
||||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(deckZone.play);
|
||||
const SampleBands bands =
|
||||
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
|
||||
// Title: product name + live readout. Standard B palette — the beta channel gets NO distinct
|
||||
// accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable
|
||||
// signal.
|
||||
std::string title = version::vstPluginName(); // channel-derived (S18)
|
||||
// Title: product name + live readout. The beta channel gets no distinct accent; the
|
||||
// channel-derived vstPluginName is the only beta-vs-stable signal.
|
||||
std::string title = version::vstPluginName();
|
||||
if (processor_ && processor_->bridge().isConnected()) {
|
||||
// The instance's OWN loaded state outranks bank availability (pS: the bank is a
|
||||
// browser source, not the instrument's identity) — a self-contained instance names
|
||||
// its sound (refs displayName fallback) even when the bank snapshot is empty.
|
||||
// The instance's own loaded state outranks bank availability (the bank is a browser
|
||||
// source, not the instrument's identity) — a self-contained instance names its sound
|
||||
// (refs displayName fallback) even when the bank snapshot is empty.
|
||||
if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
|
||||
else if (!selectedId_.empty())
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
|
||||
@@ -128,17 +126,17 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
}
|
||||
|
||||
// Resolve the effective single-capture zone: the picked id's one-zone override when present,
|
||||
// else the product-default play params (S15-F2 — the single capture is a one-zone map). This
|
||||
// is the ONE storage site both Sample and Zone edit.
|
||||
// else the product-default play params (the single capture is a one-zone map). This is the
|
||||
// one storage site both Sample and Zone edit.
|
||||
const PerformanceZone& zone = deckZone;
|
||||
|
||||
// --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay -----------
|
||||
// Hero waveform band: envelope + markers + envelope overlay.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
const Rect waveArea = bands.hero;
|
||||
fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest);
|
||||
if (frames > 0 && waveArea.width > 0) {
|
||||
// FA3 gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
|
||||
// Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
|
||||
// multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
|
||||
// partition — extra bins produce no visible change. Clamped to frame count below.
|
||||
const std::int64_t wantBins =
|
||||
@@ -168,14 +166,14 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
toLice(roleColor(markerRoles[i])), alpha, 0);
|
||||
}
|
||||
|
||||
// S-VIEW-3: trace the amp-envelope overlay + its draggable node handles over the hero.
|
||||
// Trace the amp-envelope overlay + its draggable node handles over the hero.
|
||||
paintEnvelopeOverlay(bmp, waveArea, zone, frames);
|
||||
} else {
|
||||
kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
|
||||
}
|
||||
|
||||
// --- Root + preview cluster (r11: remainder-width root strip, preview button, radial
|
||||
// velocity knob, mini curve-preview button, channel toggle) -----------------------------
|
||||
// Root + preview cluster: remainder-width root strip, preview button, radial velocity
|
||||
// knob, mini curve-preview button, channel toggle.
|
||||
fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest);
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
|
||||
@@ -193,7 +191,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||||
drawButton(bmp, box, "Preview", st, /*warn=*/false);
|
||||
}
|
||||
// Preview velocity: a RADIAL knob cell (r11 — the deck cell grammar), bound to the same
|
||||
// Preview velocity: a radial knob cell (the deck cell grammar), bound to the same
|
||||
// persisted previewVelocity seam. Label swaps to the live value during hover/drag.
|
||||
{
|
||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2);
|
||||
@@ -211,8 +209,8 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
// The mini curve-preview button (r11): opens the popup editor. Shared painter with the
|
||||
// Zone panel's button (FB2 — one grammar on both surfaces).
|
||||
// The mini curve-preview button: opens the popup editor. Shared painter with the Zone
|
||||
// panel's button — one grammar on both surfaces.
|
||||
paintCurveButton(bmp, cr.curveBtn, zone);
|
||||
// Mono | Stereo output-mode toggle.
|
||||
{
|
||||
@@ -227,10 +225,10 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
}
|
||||
|
||||
// --- The knob deck (r11: the fenced control groups, bottom-anchored) -------------------
|
||||
// The knob deck: the fenced control groups, bottom-anchored.
|
||||
paintKnobDeck(bmp, bands.deck, zone, deckDescs);
|
||||
|
||||
// --- The curve popup (r11): a centered sheet over the whole Sample face, drawn LAST ----
|
||||
// The curve popup: a centered sheet over the whole Sample face, drawn last.
|
||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||||
}
|
||||
|
||||
@@ -252,11 +250,11 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA
|
||||
const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x));
|
||||
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
|
||||
}
|
||||
// Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw-
|
||||
// only). Lit accent-hot when this node is the grabbed one. FA2 guarantees every vertex is
|
||||
// in-bounds (the pre-FA2 right-edge clip is dead and removed — edge nodes like ReleaseEnd
|
||||
// at area.right()-1 MUST get handles); the handle SQUARE is additionally clamped inside the
|
||||
// hero rect so a 6px box on an edge node never overhangs into the neighbouring bands.
|
||||
// Draggable node handles: a small square per draggable node (Origin + ReleaseStart are
|
||||
// draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is
|
||||
// guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles);
|
||||
// the handle square is additionally clamped inside the hero rect so a 6px box on an edge
|
||||
// node never overhangs into the neighbouring bands.
|
||||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||||
for (const EnvVertex& v : poly) {
|
||||
@@ -274,8 +272,8 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
|
||||
if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect)
|
||||
|
||||
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
|
||||
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (FB2: the
|
||||
// popup is the only host).
|
||||
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (the popup
|
||||
// is the only host).
|
||||
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
@@ -357,8 +355,8 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||||
: (seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
};
|
||||
|
||||
// The knob's short name label (swapped for the live value during hover/drag — r11: no
|
||||
// third line, no permanent value clutter).
|
||||
// The knob's short name label (swapped for the live value during hover/drag — no third
|
||||
// line, no permanent value clutter).
|
||||
const auto knobName = [](ParamControl c) -> const char* {
|
||||
switch (c) {
|
||||
case ParamControl::kAttack: return "Attack";
|
||||
@@ -395,7 +393,7 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||||
}
|
||||
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
|
||||
|
||||
// The compact caption toggle (r11: right-anchored IN the caption row, never full-width).
|
||||
// The compact caption toggle (right-anchored in the caption row, never full-width).
|
||||
if (g.captionToggle.id >= 0) {
|
||||
switch (static_cast<ParamControl>(g.captionToggle.id)) {
|
||||
case ParamControl::kPlayMode:
|
||||
@@ -422,7 +420,7 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||||
}
|
||||
|
||||
// The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off —
|
||||
// stable geometry (r11).
|
||||
// stable geometry.
|
||||
for (const DeckCellLayout& c : g.cells) {
|
||||
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
|
||||
const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled);
|
||||
@@ -444,11 +442,11 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||||
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r,
|
||||
const PerformanceZone& zone) {
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
// The mini curve-preview button (r11/FB2 — shared by the Sample cluster and the Zone
|
||||
// panel): a hairline-bordered bg/cell square with the zone's live velocity curve traced
|
||||
// in miniature (no node markers at this scale). Hover lifts it; it draws ACTIVE
|
||||
// (accent-primary border) while its popup is open, and re-renders live as the popup
|
||||
// edits the curve (same zone, re-read each paint).
|
||||
// The mini curve-preview button (shared by the Sample cluster and the Zone panel): a
|
||||
// hairline-bordered bg/cell square with the zone's live velocity curve traced in
|
||||
// miniature (no node markers at this scale). Hover lifts it; it draws Active
|
||||
// (accent-primary border) while its popup is open, and re-renders live as the popup edits
|
||||
// the curve (same zone, re-read each paint).
|
||||
const bool hov = isHovered(HoverKind::kCurveButton, -1);
|
||||
fillSurface(bmp, toKitBox(r), Role::BgCell,
|
||||
hov ? InteractionState::Hover : InteractionState::Rest);
|
||||
@@ -489,10 +487,10 @@ void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
|
||||
: InteractionState::Rest;
|
||||
drawButton(bmp, box, "x", st, /*warn=*/false);
|
||||
}
|
||||
// The full-size editor: ONE draw path + the one curveBoxFromRect mapping formula, so
|
||||
// The full-size editor: one draw path + the one curveBoxFromRect mapping formula, so
|
||||
// trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() —
|
||||
// the picked capture's one-zone site on the Sample face, the selected zone on the Zone
|
||||
// surface (FB2).
|
||||
// surface.
|
||||
paintVelocityCurve(bmp, pl.curveBox, popupZone());
|
||||
}
|
||||
|
||||
@@ -502,9 +500,9 @@ void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
|
||||
const char* msg = samples_.empty()
|
||||
? "No captures in this project yet - capture audio into the bank to play it here."
|
||||
: "No captures in this bank filter. Choose another bank tab above.";
|
||||
// Split the area so the primary line sits centered and the S13 ingest affordance sits just
|
||||
// below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept
|
||||
// discoverable here regardless of whether a drop ever lands on THIS window.
|
||||
// Split the area so the primary line sits centered and the ingest affordance sits just
|
||||
// below it. The affordance is the shipped ingest gesture (drop onto the docked panel) —
|
||||
// kept discoverable here regardless of whether a drop ever lands on this window.
|
||||
Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2);
|
||||
Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom());
|
||||
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
// editor_platform.cpp — the ReaSamplerEditor's IPlugView + Win32 window plumbing (Q-W2v
|
||||
// split of reasampler_editor.cpp, T4-11): platform-type/resize negotiation, the child
|
||||
// window class + creation/destruction, the S9/S8 sync timer lifetime, the WM_* dispatch
|
||||
// (wndProc — paint, mouse, keyboard, capture-loss rollback, drop-accept, timer), and the
|
||||
// non-Windows stubs (D5 makes Windows the only build target; the TU still compiles
|
||||
// elsewhere).
|
||||
// editor_platform.cpp — the ReaSamplerEditor's IPlugView + Win32 window plumbing:
|
||||
// platform-type/resize negotiation, the child window class + creation/destruction, the
|
||||
// sync timer lifetime, the WM_* dispatch (wndProc — paint, mouse, keyboard, capture-loss
|
||||
// rollback, drop-accept, timer), and the non-Windows stubs (Windows is the only build
|
||||
// target; the TU still compiles elsewhere).
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
|
||||
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — S13 drop-accept
|
||||
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — drop-accept
|
||||
#endif
|
||||
|
||||
#include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only)
|
||||
@@ -23,11 +22,11 @@ namespace reasampler::vst {
|
||||
namespace {
|
||||
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
|
||||
|
||||
// The S9/S8 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" (the S9 verify criterion) 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).
|
||||
// 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).
|
||||
constexpr UINT_PTR kSyncTimerId = 1;
|
||||
constexpr UINT kSyncTimerIntervalMs = 500;
|
||||
} // namespace
|
||||
@@ -45,12 +44,12 @@ tresult PLUGIN_API ReaSamplerEditor::canResize() {
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) {
|
||||
// Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before
|
||||
// every resize; clamp the proposed rect in place and return kResultTrue so the host applies the
|
||||
// (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title
|
||||
// + hero waveform + cluster + a few control rows visible (the control strip clips gracefully
|
||||
// below the panel bottom); anything smaller would clip essential UI. The default 840×620 is
|
||||
// above this floor.
|
||||
// Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this
|
||||
// before every resize; clamp the proposed rect in place and return kResultTrue so the host
|
||||
// applies the (possibly adjusted) rect rather than the raw user drag. 560x460 keeps the
|
||||
// Sample face's title + hero waveform + cluster + a few control rows visible (the control
|
||||
// strip clips gracefully below the panel bottom); anything smaller would clip essential UI.
|
||||
// The default 840x620 is above this floor.
|
||||
constexpr int kMinW = 560;
|
||||
constexpr int kMinH = 460;
|
||||
if (!rect) return kResultFalse;
|
||||
@@ -85,11 +84,11 @@ void ReaSamplerEditor::attachedToParent() {
|
||||
classRegistered = true;
|
||||
}
|
||||
|
||||
// Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a
|
||||
// reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on
|
||||
// editor close: the embed strip in the SAME binary shares the kit's process-global font
|
||||
// set, so a per-view shutdown could free fonts still in use by the other view. The tiny
|
||||
// static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note.
|
||||
// Create the kit's cached AA fonts before the first paint. Idempotent, so a reopen (or a
|
||||
// co-resident embed strip that also inits) is a cheap no-op. Not torn down on editor close:
|
||||
// the embed strip in the same binary shares the kit's process-global font set, so a
|
||||
// per-view shutdown could free fonts still in use by the other view. The tiny static HFONT
|
||||
// set is reclaimed by the OS at module unload.
|
||||
kitFontsInit();
|
||||
|
||||
refreshFromBank();
|
||||
@@ -99,17 +98,17 @@ void ReaSamplerEditor::attachedToParent() {
|
||||
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
|
||||
if (childHwnd_) {
|
||||
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
|
||||
// S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT
|
||||
// ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show
|
||||
// the "drop on the panel" affordance instead of the OS bouncing the drop silently.
|
||||
// Accept OS file drops on the editor window (WM_DROPFILES). The drop is not ingested
|
||||
// here (the relay is degraded — see onFilesDropped); accepting it lets us show the
|
||||
// "drop on the panel" affordance instead of the OS bouncing the drop silently.
|
||||
DragAcceptFiles(childHwnd_, TRUE);
|
||||
// Start the S9/S8 change-detection poll (UI thread). Tied to the child window's
|
||||
// lifetime — created here, killed in removedFromParent — so an instance whose editor
|
||||
// is closed does NOT poll (the editor-open-only cadence; see the handoff limitation).
|
||||
// Start the change-detection poll (UI thread). Tied to the child window's lifetime —
|
||||
// created here, killed in removedFromParent — so an instance whose editor is closed
|
||||
// does not poll.
|
||||
SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr);
|
||||
// Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor
|
||||
// was closed) or a bank change applies the instant the editor opens, rather than waiting
|
||||
// up to one timer interval. refreshFromBank above already primed the view; this folds in
|
||||
// Poll once immediately so a pending assignment (an ingest fired while this editor was
|
||||
// closed) or a bank change applies the instant the editor opens, rather than waiting up
|
||||
// to one timer interval. refreshFromBank above already primed the view; this folds in
|
||||
// any pending assign/generation so the just-opened editor shows the assigned capture.
|
||||
onSyncTimer();
|
||||
}
|
||||
@@ -147,7 +146,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
case WM_LBUTTONDOWN:
|
||||
if (self) {
|
||||
SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child
|
||||
SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12)
|
||||
SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box
|
||||
self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||
}
|
||||
return 0;
|
||||
@@ -155,9 +154,9 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
if (self) {
|
||||
const int mx = GET_X_LPARAM(lParam);
|
||||
const int my = GET_Y_LPARAM(lParam);
|
||||
// Hover feedback (Phase L, L3): resolve the element under the pointer and
|
||||
// repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover
|
||||
// clears when the pointer leaves the child (TrackMouseEvent is one-shot).
|
||||
// Hover feedback: resolve the element under the pointer and repaint on change.
|
||||
// Arm WM_MOUSELEAVE once per "over" cycle so the hover clears when the pointer
|
||||
// leaves the child (TrackMouseEvent is one-shot).
|
||||
if (!self->mouseTracking_) {
|
||||
TRACKMOUSEEVENT tme{};
|
||||
tme.cbSize = sizeof(tme);
|
||||
@@ -182,15 +181,15 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
}
|
||||
return 0;
|
||||
case WM_MOUSEWHEEL:
|
||||
// S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up.
|
||||
// Browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up.
|
||||
if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam));
|
||||
return 0;
|
||||
case WM_CHAR:
|
||||
// S12 type-to-filter search keystrokes (only acted on when the search box is focused).
|
||||
// Type-to-filter search keystrokes (only acted on when the search box is focused).
|
||||
if (self) self->onSearchChar(static_cast<unsigned int>(wParam));
|
||||
return 0;
|
||||
case WM_GETDLGCODE:
|
||||
// Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search).
|
||||
// Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (search).
|
||||
return DLGC_WANTCHARS | DLGC_WANTARROWS;
|
||||
case WM_LBUTTONUP:
|
||||
if (self) {
|
||||
@@ -199,8 +198,8 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
}
|
||||
return 0;
|
||||
case WM_RBUTTONDOWN:
|
||||
// r11: right-click — the curve popup's primary node-delete affordance (issue 3c).
|
||||
// Routed explicitly (the child wndproc historically handled only left-button).
|
||||
// Right-click — the curve popup's primary node-delete affordance. Routed
|
||||
// explicitly (the child wndproc historically handled only left-button).
|
||||
if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||
return 0;
|
||||
case WM_RBUTTONUP:
|
||||
@@ -232,16 +231,16 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
self->drag_ = DragKind::kNone;
|
||||
self->dragParamId_ = -1;
|
||||
self->dragParamZone_ = -1;
|
||||
self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset)
|
||||
self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
|
||||
self->dragCurveZone_ = -1;
|
||||
self->invalidate();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_DROPFILES: {
|
||||
// S13 (relay degraded): count the dropped files and flash the affordance. We do NOT
|
||||
// read/ingest the paths (the instrument never ingests — the relay to the extension is
|
||||
// unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner.
|
||||
// Count the dropped files and flash the affordance. We do not read/ingest the paths
|
||||
// (the instrument never ingests — the relay to the extension is unshipped);
|
||||
// DragQueryFile with 0xFFFFFFFF just returns the count for the banner.
|
||||
HDROP drop = reinterpret_cast<HDROP>(wParam);
|
||||
const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
|
||||
DragFinish(drop);
|
||||
@@ -258,7 +257,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
}
|
||||
}
|
||||
|
||||
#else // non-Windows: not a build target (D5), but keep the TU compilable.
|
||||
#else // non-Windows: not a build target, but keep the TU compilable.
|
||||
|
||||
void ReaSamplerEditor::attachedToParent() {}
|
||||
void ReaSamplerEditor::removedFromParent() {}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// editor_session.cpp — the ReaSamplerEditor's SESSION/BRIDGE state (Q-W2v split of
|
||||
// reasampler_editor.cpp, T4-11): construction, the live-bank snapshot (refreshFromBank /
|
||||
// rebuildVisible), the S9/S8 sync tick, the commit-and-reload seam, selection loading,
|
||||
// the picked-capture marker resolution/upsert helpers, and the decoded-PCM + peak
|
||||
// thumbnail caches (the mirror of bank_panel's, keyed through the pure ThumbnailKey —
|
||||
// T2-10 rider). UI thread only; every edit commits OFF the audio thread via the
|
||||
// processor's reloadInstrument.
|
||||
// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the
|
||||
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the
|
||||
// commit-and-reload seam, selection loading, the picked-capture marker resolution/upsert
|
||||
// helpers, and the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits
|
||||
// off the audio thread via the processor's reloadInstrument.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -14,12 +12,12 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // computeEnvelope (the cached peak thumbnail)
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution)
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames
|
||||
#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (T2-10: the pure key)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#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/ui/browser_scroll.h" // nameMatchesQuery (S12 type-to-filter)
|
||||
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter)
|
||||
#include "shell/instrument/reaper_bridge.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -40,11 +38,8 @@ using util::readFileBytes;
|
||||
|
||||
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
|
||||
: CPluginView(nullptr), processor_(processor) {
|
||||
// Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample
|
||||
// home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate
|
||||
// mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll
|
||||
// on a 1080p screen with headroom. Wide enough that the control strip's label + value columns
|
||||
// read comfortably.
|
||||
// Default view size, tuned to the Sample-face band heights: title + hero waveform +
|
||||
// cluster + control strip. 840x620 clears the full face without scroll on 1080p.
|
||||
ViewRect r(0, 0, 840, 620);
|
||||
setRect(r);
|
||||
}
|
||||
@@ -52,7 +47,7 @@ ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
|
||||
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 S11 waveform + snap source)
|
||||
pcmCache_.clear(); // and its decoded PCM (the waveform + snap source)
|
||||
if (!processor_) {
|
||||
samples_.clear();
|
||||
banks_.clear();
|
||||
@@ -69,17 +64,15 @@ void ReaSamplerEditor::refreshFromBank() {
|
||||
const auto prevZoneCount = static_cast<int>(map_.zones.size());
|
||||
map_ = processor_->performanceMap();
|
||||
channelMode_ = processor_->channelMode();
|
||||
voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot
|
||||
voiceCount_ = processor_->voiceCount();
|
||||
voiceMode_ = processor_->voiceMode();
|
||||
monoTrigger_ = processor_->monoTrigger();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
// r11: a refresh that emptied the selection (a bank change on the sync tick) closes the
|
||||
// curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible
|
||||
// modal would swallow clicks.
|
||||
// A refresh that emptied the selection closes the curve popup — an open-but-invisible
|
||||
// modal would otherwise swallow clicks on the empty state.
|
||||
if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false;
|
||||
// FB2: on the Zone surface the popup edits the SELECTED zone; close it if the zones list
|
||||
// shrank (selectedZone_ past-end), OR if the zone count changed at all — a mid-list
|
||||
// deletion leaves selectedZone_ in range but now naming a DIFFERENT zone (silent retarget).
|
||||
// On the Zone surface, close the popup if the zone count changed at all — a mid-list
|
||||
// deletion can leave selectedZone_ in range but silently naming a different zone.
|
||||
if (view_ == View::kZone && curvePopupOpen_) {
|
||||
const auto newZoneCount = static_cast<int>(map_.zones.size());
|
||||
if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false;
|
||||
@@ -94,8 +87,7 @@ void ReaSamplerEditor::refreshFromBank() {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::rebuildVisible() {
|
||||
// S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search
|
||||
// narrows the survivors by name substring (nameMatchesQuery — empty query is the identity).
|
||||
// Bank filter first, then type-to-filter search narrows by name substring.
|
||||
visible_.clear();
|
||||
for (const SampleChoice& s : samples_) {
|
||||
const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_;
|
||||
@@ -103,39 +95,30 @@ void ReaSamplerEditor::rebuildVisible() {
|
||||
const std::string& name = s.displayName.empty() ? s.id : s.displayName;
|
||||
if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s);
|
||||
}
|
||||
// NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel
|
||||
// height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it
|
||||
// must not reset the user's scroll here.
|
||||
// scrollOffset_ is clamped at paint/wheel time (where layout is known); this runs on
|
||||
// the sync-timer refresh too, so it must not reset the user's scroll here.
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared
|
||||
// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes
|
||||
// Windows the only build target, but the TU must still compile elsewhere).
|
||||
// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path).
|
||||
void ReaSamplerEditor::onSyncTimer() {
|
||||
// UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the
|
||||
// processor (off the audio thread — the poll itself never touches process()). NEVER while a
|
||||
// drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the
|
||||
// user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release.
|
||||
// UI thread (WM_TIMER). Never while a drag is in flight: a reload mid-drag would
|
||||
// rebuild the instrument and repaint under the cursor, yanking the edit — the next
|
||||
// tick picks up the change after release.
|
||||
if (!processor_) return;
|
||||
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
|
||||
|
||||
// An open editor marks THIS instance the focused assignment target (the thundering-herd
|
||||
// policy — only an editor-open instance applies a pending assign; see the handoff). Pass
|
||||
// true so this instance consumes the request; instances with no editor open do not poll at
|
||||
// all (the timer is bound to the child window), so they never contend for the request.
|
||||
// An open editor is the focused assignment target (thundering-herd policy); instances
|
||||
// with no editor open never poll (the timer is bound to the child window).
|
||||
const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true);
|
||||
|
||||
// Re-snapshot the editor's own view only when something changed (a reload from a bank
|
||||
// content change, or an applied assignment). refreshFromBank re-reads the bank blob + the
|
||||
// processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM
|
||||
// caches, then repaints — so the browser + setup surface reflect the new bank hands-free.
|
||||
// Re-snapshot only when something changed.
|
||||
if (r.reloaded || r.applied) {
|
||||
refreshFromBank();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
// S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop.
|
||||
// Decay the drop-affordance banner so it auto-dismisses a few ticks after a drop.
|
||||
if (dropHintTicks_ > 0) {
|
||||
--dropHintTicks_;
|
||||
invalidate();
|
||||
@@ -144,18 +127,16 @@ void ReaSamplerEditor::onSyncTimer() {
|
||||
#endif // _WIN32
|
||||
|
||||
void ReaSamplerEditor::commitAndReload() {
|
||||
// UI thread only. Publish the edited selection + zones to the processor, then rebuild
|
||||
// the instrument off the audio thread (reloadInstrument bakes them into the live Keymap).
|
||||
// pS: the reload also COPIES the picked capture's file ref + intrinsics from the bank
|
||||
// blob into the instance-owned refs table (refreshRefsFromBank) — a browser load is the
|
||||
// moment the instance becomes self-contained for that sample.
|
||||
// UI thread only. Publishes the edited selection + zones, then rebuilds off the audio
|
||||
// thread. The reload also copies the picked capture's file ref + intrinsics into the
|
||||
// instance-owned refs table — a browser load is the moment the instance becomes
|
||||
// self-contained for that sample.
|
||||
if (!processor_) return;
|
||||
processor_->setSelectedSampleId(selectedId_);
|
||||
processor_->setPerformanceMap(map_);
|
||||
processor_->reloadInstrument();
|
||||
// GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's
|
||||
// channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode
|
||||
// the engine actually decoded with.
|
||||
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
|
||||
// toggle draws what the engine actually decoded with.
|
||||
channelMode_ = processor_->channelMode();
|
||||
#ifdef _WIN32
|
||||
invalidate();
|
||||
@@ -163,10 +144,9 @@ void ReaSamplerEditor::commitAndReload() {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::loadSelection(const std::string& id) {
|
||||
// Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous
|
||||
// sample's materialized full-range zone must not linger — first-match resolve would
|
||||
// keep playing it while the editor draws the new pick's zone (matched by sampleId,
|
||||
// order-blind). Authored Zone-view maps (any narrow key range) are left untouched.
|
||||
// A Sample-face load REPLACES the loaded sound: the previous sample's materialized
|
||||
// full-range zone must not linger, or first-match resolve would keep playing it.
|
||||
// Authored Zone-view maps (narrow key ranges) are left untouched.
|
||||
selectedId_ = id;
|
||||
if (reconcileSingleCaptureZones(map_, selectedId_)) {
|
||||
selectedZone_ = map_.zones.empty() ? -1 : 0;
|
||||
@@ -176,11 +156,11 @@ void ReaSamplerEditor::loadSelection(const std::string& id) {
|
||||
|
||||
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
|
||||
SetupMarkers m;
|
||||
// Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override
|
||||
// for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic
|
||||
// from the live bank blob (the same path selectSample uses); when that is not readable
|
||||
// (extension absent / not yet parsed) the instance-OWNED ref carries the same intrinsics
|
||||
// (pS fallback). The override lives in map_.
|
||||
// Seed from the bank's intrinsic loop (fact about the file), then let a per-zone override
|
||||
// for the picked id win (the instrument's performance choice). Read the loop intrinsic from
|
||||
// the live bank blob (the same path selectSample uses); when that is not readable (extension
|
||||
// absent / not yet parsed) the instance-owned ref carries the same intrinsics. The override
|
||||
// lives in map_.
|
||||
if (processor_) {
|
||||
std::optional<SelectedSample> sel;
|
||||
auto banksJson =
|
||||
@@ -215,10 +195,10 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
|
||||
// Find-or-append the zone for selectedId_ and write the loop/start override fields.
|
||||
// The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must
|
||||
// be non-empty; callers are responsible for that guard.
|
||||
// Returns the zone index (0-based) so callers can update selectedZone_.
|
||||
// Find-or-append the zone for selectedId_ and write the loop/start override fields. The
|
||||
// bank intrinsic is never written (read-only bank consumer). selectedId_ must be
|
||||
// non-empty; callers are responsible for that guard. Returns the zone index (0-based) so
|
||||
// callers can update selectedZone_.
|
||||
SampleLoop loop;
|
||||
loop.hasLoop = m.hasLoop;
|
||||
loop.start = m.loopStart;
|
||||
@@ -243,8 +223,8 @@ int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
|
||||
|
||||
PerformanceZone ReaSamplerEditor::effectiveSampleZone() const {
|
||||
// The picked id's one-zone override, if the map already carries one; else a product-default
|
||||
// zone bound to the picked id (NOT appended — a read-only resolve; a control edit materializes
|
||||
// it via ensureSampleZone). Mirrors the S15-F2 single-storage-site lean.
|
||||
// zone bound to the picked id (not appended — a read-only resolve; a control edit
|
||||
// materializes it via ensureSampleZone).
|
||||
for (const PerformanceZone& z : map_.zones) {
|
||||
if (z.sampleId == selectedId_) return z;
|
||||
}
|
||||
@@ -280,11 +260,10 @@ int ReaSamplerEditor::ensureSampleZone() {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
|
||||
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert,
|
||||
// mirror of the root-marker path): a full-keyboard zone carrying the override. This plays
|
||||
// identically to the un-zoned single capture (one chromatic zone) and round-trips through
|
||||
// the component state; the zone becomes visible if the user opens the Zones panel. The bank
|
||||
// intrinsic is NEVER written (read-only bank consumer, D-B).
|
||||
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert):
|
||||
// a full-keyboard zone carrying the override. This plays identically to the un-zoned single
|
||||
// capture (one chromatic zone) and round-trips through the component state; the zone becomes
|
||||
// visible if the user opens the Zones panel. The bank intrinsic is never written.
|
||||
if (selectedId_.empty()) return;
|
||||
upsertPickedOverride(m);
|
||||
commitAndReload();
|
||||
@@ -294,11 +273,11 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
auto it = pcmCache_.find(sampleId);
|
||||
if (it != pcmCache_.end()) return it->second;
|
||||
|
||||
// SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so
|
||||
// SampleChoice is the browser's metadata projection and does not carry the WAV path, so
|
||||
// resolve the path from the live bank blob (selectSample) and decode via the shared WAV
|
||||
// parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY
|
||||
// vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width-
|
||||
// independent) — the thumbnail bins this at whatever width, the snap scans it directly.
|
||||
// parse. Every failure path caches an empty vector so a broken/missing file is not
|
||||
// re-decoded on every paint. Keyed by id (width-independent) — the thumbnail bins this at
|
||||
// whatever width, the snap scans it directly.
|
||||
std::string relativePath;
|
||||
std::vector<AudioSample> mono;
|
||||
if (processor_) {
|
||||
@@ -308,9 +287,9 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath;
|
||||
}
|
||||
if (relativePath.empty()) {
|
||||
// pS fallback: the bank blob is not readable (extension absent / not yet parsed)
|
||||
// or the id went stale there — the instance-OWNED ref still carries the path, so
|
||||
// a self-contained instance draws its loaded sound's waveform regardless.
|
||||
// Fallback: the bank blob is not readable (extension absent / not yet parsed) or
|
||||
// the id went stale there — the instance-owned ref still carries the path, so a
|
||||
// self-contained instance draws its loaded sound's waveform regardless.
|
||||
const SampleRefs refs = processor_->sampleRefs();
|
||||
if (const SelectedSample* r = findRef(refs, sampleId)) {
|
||||
relativePath = r->relativePath;
|
||||
@@ -319,8 +298,7 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
if (!relativePath.empty()) {
|
||||
const std::string projectDir = processor_->bridge().activeProjectDir();
|
||||
const std::string abs = resolveBankFile(projectDir, relativePath);
|
||||
// Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure.
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs); // empty on any failure
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (layout.valid) {
|
||||
std::vector<AudioSample> interleaved =
|
||||
@@ -334,17 +312,16 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
}
|
||||
|
||||
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
|
||||
// T2-10 rider: key through the PURE ThumbnailKey (bank_grid) instead of the former
|
||||
// ad-hoc "id|binCount" concat, so both thumbnail pipelines share one tested key
|
||||
// grammar (length-prefixed id — collision-proof). The editor invalidates by wholesale
|
||||
// clear() on refresh/resize, so the bank generation carries no information here — 0.
|
||||
// Key through the pure ThumbnailKey (bank_grid, length-prefixed id — collision-proof) so
|
||||
// both thumbnail pipelines share one tested key grammar. The editor invalidates by
|
||||
// wholesale clear() on refresh/resize, so the bank generation carries no information here.
|
||||
const std::string key =
|
||||
thumbnailKeyString(ThumbnailKey{sampleId, binCount, /*generation=*/0});
|
||||
auto it = thumbCache_.find(key);
|
||||
if (it != thumbCache_.end()) return it->second;
|
||||
|
||||
// Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by
|
||||
// every thumbnail width AND the S11 waveform surface + snap.
|
||||
// every thumbnail width AND the waveform surface + snap.
|
||||
const std::vector<AudioSample>& mono = monoPcmFor(sampleId);
|
||||
Envelope env;
|
||||
if (!mono.empty()) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// processor_reload.cpp — the ReaSamplerProcessor's OFF-AUDIO-THREAD instrument
|
||||
// lifecycle: reloadInstrument (self-contained refs resolve + WAV decode + keymap
|
||||
// build), the safety-critical publishBuiltLocked drain-slot swap, the voice-param
|
||||
// light rebuild, idle-drain retirement, the pre-v10 legacy-lift gate, the S9/S8
|
||||
// bank-sync poll, and the pS-usage publish. Split out of reasampler_processor.cpp
|
||||
// (Q-W2v, T4-12). NOTHING here runs on the audio thread — process() (the lifecycle
|
||||
// TU) only touches the atomics this family publishes; the atomic-pointer-swap
|
||||
// pattern deliberately gains NO virtual seam (T4-29).
|
||||
// processor_reload.cpp — ReaSamplerProcessor's off-audio-thread instrument lifecycle:
|
||||
// reloadInstrument (self-contained refs resolve + WAV decode + keymap build), the
|
||||
// safety-critical publishBuiltLocked drain-slot swap, the voice-param light rebuild,
|
||||
// idle-drain retirement, the pre-v10 legacy-lift gate, the bank-sync poll, and the
|
||||
// usage publish. Nothing here runs on the audio thread — process() only touches the
|
||||
// atomics this family publishes; the atomic-pointer-swap pattern gains no virtual seam.
|
||||
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -18,20 +16,19 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution)
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
#include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision
|
||||
#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (pS self-contained)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
|
||||
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (S8 request wire parse)
|
||||
#include "core/wire/sample_usage.h" // pS-usage publish plan + wire (prune-protection seam)
|
||||
#include "core/instrument/map/bank_sync.h" // pure decisions: parseBankGeneration, consumeDecision
|
||||
#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (self-contained)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader
|
||||
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse)
|
||||
#include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam)
|
||||
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace instrument::map; // resolution + bank-sync vocabulary this TU drives
|
||||
using namespace reasampler::wire; // assignment_request + sample_usage wire records
|
||||
// Q-W6 (shim retired): the shared WAV parse + file loader by their real homes.
|
||||
using capture::extractFloatFrames;
|
||||
using capture::parseWavLayout;
|
||||
using capture::resolveBankFile;
|
||||
@@ -40,20 +37,15 @@ using util::readFileBytes;
|
||||
|
||||
namespace {
|
||||
|
||||
// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
|
||||
// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather
|
||||
// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice
|
||||
// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the
|
||||
// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays
|
||||
// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget.
|
||||
// Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
|
||||
// materially heavier than a Varispeed voice, so a note-on past the cap is dropped rather
|
||||
// than glitching. 8 is conservative pending DAW profiling; fixed regardless of the
|
||||
// user-set voiceCount (1..32) so raising polyphony never multiplies shifter CPU past budget.
|
||||
constexpr std::size_t kPreserveVoiceCap = 8;
|
||||
|
||||
// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy
|
||||
// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the
|
||||
// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic
|
||||
// strength) is the requirement: two instances sharing a key is the copy-collision
|
||||
// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision
|
||||
// vanishingly unlikely. Off-thread only.
|
||||
// Mints a fresh publish identity (32 lowercase hex chars) for either the persisted
|
||||
// instanceGuid_ or the in-memory usageNonce_. Uniqueness, not cryptographic strength, is
|
||||
// the requirement — planUsagePublish resolves a collision fail-safe anyway.
|
||||
std::string mintUsageInstanceGuid() {
|
||||
std::random_device rd;
|
||||
std::mt19937_64 gen((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
|
||||
@@ -65,17 +57,11 @@ std::string mintUsageInstanceGuid() {
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03).
|
||||
// Off-thread only (blocking file I/O). Empty on any failure — the caller treats
|
||||
// an unreadable WAV as "nothing to play".
|
||||
|
||||
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
|
||||
// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode
|
||||
// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for
|
||||
// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path
|
||||
// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no
|
||||
// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by
|
||||
// the zoned build and the single-capture path so both decode identically for the active mode.
|
||||
// Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only),
|
||||
// and applies the cross-mode channel policy for `mode` (mono downmix; stereo -> dual-mono
|
||||
// for a mono source, L/R for a stereo source — see decodeChannels). Returns nullopt on any
|
||||
// resolve/read/decode failure — the caller drops the zone or plays silence. Shared by the
|
||||
// zoned build and the single-capture path.
|
||||
std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||
const std::string& relativePath,
|
||||
ChannelMode mode) {
|
||||
@@ -95,22 +81,19 @@ std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||
} // namespace
|
||||
|
||||
std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
// OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so
|
||||
// the retired-slot free is single-writer. This mutex is NEVER taken on the audio
|
||||
// thread — process() only touches the atomic.
|
||||
// OFF THE AUDIO THREAD. Serializes concurrent reloads (editor click + setState) so the
|
||||
// retired-slot free is single-writer; never taken on the audio thread.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
|
||||
// Mint this reload's generation number first so we can stamp the built instrument
|
||||
// with it before publishing. Under reloadMutex_ no other reload races here.
|
||||
// Mint this reload's generation number first so the built instrument is stamped
|
||||
// before publishing.
|
||||
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
|
||||
// 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of
|
||||
// truth for what to decode. The live bank blob, WHEN readable, is folded into the
|
||||
// table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in
|
||||
// mechanism and the S9 recapture sync in one — but its absence changes NOTHING
|
||||
// below: a project restored before the extension's PROJEXTSTATE parses (or with
|
||||
// the extension absent entirely) resolves + plays from the persisted refs. The
|
||||
// project dir comes from REAPER itself (EnumProjects), not from the extension.
|
||||
// 1. Self-contained resolution: the instance-owned refs table is the source of truth.
|
||||
// The live bank blob, when readable, is folded in first (refreshRefsFromBank — the
|
||||
// browser's copy-the-ref-in + recapture-sync mechanism), but its absence changes
|
||||
// nothing below — a project restored before PROJEXTSTATE parses (or with the
|
||||
// extension absent) resolves + plays from the persisted refs.
|
||||
const std::string selId = selectedSampleId();
|
||||
const PerformanceMap map = performanceMap();
|
||||
const std::vector<std::string> ids = referencedSampleIds(selId, map);
|
||||
@@ -120,20 +103,18 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey);
|
||||
std::lock_guard<std::mutex> rl(refsMutex_);
|
||||
if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids);
|
||||
// The LOAD path never prunes the owned table: dropping entries here on a transient
|
||||
// bank miss could destroy the owned intrinsics of the previous selection — the ONE
|
||||
// copy that survives with the extension absent. Entries for de-referenced ids stay
|
||||
// in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary,
|
||||
// where getState filters its snapshot via retainRefs to what the instance plays.
|
||||
// The LOAD path never prunes the owned table: dropping entries on a transient bank
|
||||
// miss could destroy the owned intrinsics of the previous selection — the ONE copy
|
||||
// that survives with the extension absent. Hygiene lives at the PERSIST boundary
|
||||
// (getState filters via retainRefs to what the instance plays).
|
||||
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
|
||||
}
|
||||
const std::string projectDir = bridge_.activeProjectDir();
|
||||
// The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel).
|
||||
// Read once under its mutex, off the audio thread, before the decode loop. The single-
|
||||
// capture branch below may auto-default it (GA) before its decode.
|
||||
// Governs how each WAV decodes (mono downmix vs 2-channel); the single-capture branch
|
||||
// below may auto-default it before its decode.
|
||||
ChannelMode mode = channelMode();
|
||||
// Phase S: snapshot the voice-system parameters once — they are baked into the built
|
||||
// engine's construction (the engine's config is immutable; a later change rebuilds).
|
||||
// Snapshot the voice-system parameters once — baked into the built engine's
|
||||
// construction (immutable config; a later change rebuilds).
|
||||
int builtVoiceCount = kDefaultVoiceCount;
|
||||
VoiceMode builtVoiceMode = VoiceMode::Poly;
|
||||
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
|
||||
@@ -149,12 +130,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
Keymap km;
|
||||
bool haveKeymap = false;
|
||||
|
||||
// 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its
|
||||
// zones against the OWNED refs (an id with no ref drops cleanly), decode each
|
||||
// zone's WAV off-thread, and build the ZONED keymap. Each surviving zone plays
|
||||
// its sample repitched from its effective root note (override > ref intrinsic >
|
||||
// C4). A zone whose WAV fails to decode — a MISSING FILE included — is dropped
|
||||
// (not the whole map): the defined no-play, no crash, no retry loop.
|
||||
// 2. Zoned build: if the performance map is non-empty, resolve its zones against the
|
||||
// owned refs (an id with no ref drops cleanly), decode each zone's WAV off-thread,
|
||||
// and build the keymap. A zone whose WAV fails to decode is dropped, not the whole
|
||||
// map — the defined no-play, no crash, no retry loop.
|
||||
if (!map.empty()) {
|
||||
const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map);
|
||||
if (!resolved.zones.empty()) {
|
||||
@@ -174,19 +153,15 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Single-capture fast path (S10): an empty performance map plays the ONE
|
||||
// deliberately-selected capture chromatically across the whole keyboard, resolved
|
||||
// against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a
|
||||
// selection with no ref) resolves to nothing, so an un-picked instrument stays
|
||||
// SILENT (the editor shows its "pick a capture" empty state) rather than
|
||||
// auto-playing sample #1 (S10 policy reversal of the S4 convenience default).
|
||||
// 3. Single-capture fast path: an empty performance map plays the one selected capture
|
||||
// chromatically across the whole keyboard. No first-sample fallback: an empty
|
||||
// selection (or one with no ref) resolves to nothing, so an un-picked instrument
|
||||
// stays silent rather than auto-playing sample #1.
|
||||
if (!haveKeymap) {
|
||||
if (const SelectedSample* sel = findRef(refs, selId)) {
|
||||
// GA auto-default: channelModeFor computes the mode from the loaded capture's
|
||||
// REQUESTED channel count (always 2 for extension captures; mono only for
|
||||
// ingest-imported mono files). An unknown count (0) or explicit user choice
|
||||
// returns the current mode unchanged. Decode-only: the output bus is fixed
|
||||
// stereo, so no bus work follows a flip.
|
||||
// Auto-default: channelModeFor computes the mode from the loaded capture's
|
||||
// channel count (always 2 for extension captures; mono only for ingest-imported
|
||||
// mono files). An unknown count (0) or explicit user choice keeps the mode.
|
||||
{
|
||||
std::lock_guard<std::mutex> cm(channelModeMutex_);
|
||||
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
||||
@@ -206,10 +181,9 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
}
|
||||
|
||||
if (haveKeymap) {
|
||||
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
|
||||
// Every voice's shifter is pre-sized to this off-thread here, so process()-time
|
||||
// note-on never allocates. Floored at 2 so a valid window is always a real ring
|
||||
// (which also covers a pathological host rate <= 0 — no rate literal needed).
|
||||
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs),
|
||||
// pre-sized here so process()-time note-on never allocates. Floored at 2 so a
|
||||
// valid window is always a real ring, covering a pathological host rate <= 0 too.
|
||||
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
@@ -218,21 +192,14 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||
}
|
||||
|
||||
// 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the
|
||||
// DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices —
|
||||
// a reload never cuts a sounding note; the next note-on plays the new state. The
|
||||
// instrument evicted FROM the drain slot (two reloads old) goes to the graveyard
|
||||
// (process may still be mid-block reading it). A null `built` (no ref / unreadable
|
||||
// WAV) installs silence while the displaced tails still ring out via the drain.
|
||||
// `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted
|
||||
// pointer is re-owned by the graveyard.
|
||||
// 4. Publish: atomically install the new instrument via the drain-slot swap (see the
|
||||
// header). A null `built` (no ref / unreadable WAV) installs silence while any
|
||||
// displaced tails still ring out via the drain.
|
||||
publishBuiltLocked(std::move(built));
|
||||
|
||||
// 5. pS-usage: publish this instance's held captures so the extension's prune can
|
||||
// never reclaim them (see publishUsage). AFTER the instrument swap, still off the
|
||||
// audio thread and under reloadMutex_. Publishes regardless of decode success:
|
||||
// the holds are the refs the instance RETAINS (its play-set), not what decoded —
|
||||
// a transiently unreadable WAV must stay protected.
|
||||
// 5. Publish this instance's held captures so the extension's prune can never reclaim
|
||||
// them. Regardless of decode success: the holds are the refs the instance retains
|
||||
// (its play-set), not what decoded — a transiently unreadable WAV stays protected.
|
||||
publishUsage(refs, ids);
|
||||
return resolvedId;
|
||||
}
|
||||
@@ -252,15 +219,13 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(usageMutex_);
|
||||
// A never-published instance with nothing held writes nothing — no key litter for
|
||||
// fresh/empty instances. Once an identity exists, empties DO publish (they release
|
||||
// holds the prune would otherwise keep protecting).
|
||||
// A never-published instance with nothing held writes nothing (no key litter); once an
|
||||
// identity exists, empties do publish (releasing protected holds).
|
||||
if (instanceGuid_.empty() && mine.holds.empty()) return;
|
||||
if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
|
||||
// The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so
|
||||
// planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track
|
||||
// sibling's byte-identical hold set can never pass as ours (its nonce differs), so
|
||||
// siblings always union and never clean-replace over each other's held paths.
|
||||
// The per-lifetime owner nonce (UsageRecord.ownerNonce) lets planUsagePublish prove
|
||||
// "exactly this incarnation wrote the key" — a same-track sibling's byte-identical hold
|
||||
// set can never pass as ours, so siblings always union rather than clean-replace.
|
||||
if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid();
|
||||
mine.ownerNonce = usageNonce_;
|
||||
|
||||
@@ -268,10 +233,9 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
||||
bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_));
|
||||
const UsagePublishPlan plan = planUsagePublish(existing, mine);
|
||||
if (plan.remint) {
|
||||
// This state was cloned onto another track (FX copy / track duplication): take a
|
||||
// fresh identity and leave the original's record untouched. The abandoned old
|
||||
// identity's record dies by the extension's liveness rule when its track no
|
||||
// longer hosts an instance. getState persists the new guid on the next save.
|
||||
// Cloned onto another track (FX copy / track duplication): take a fresh identity;
|
||||
// the abandoned old record dies by the extension's liveness rule once its track no
|
||||
// longer hosts an instance.
|
||||
instanceGuid_ = mintUsageInstanceGuid();
|
||||
} else if (plan.skipWrite) {
|
||||
return; // idle tick, or a union that adds nothing — no ext-state churn
|
||||
@@ -280,15 +244,8 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
|
||||
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
|
||||
// reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance.
|
||||
//
|
||||
// Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is
|
||||
// the minimum installedAt process() published over the pointers it holds. Both
|
||||
// slots are monotone in installedAt, so seen is monotone and any future process()
|
||||
// load yields installedAt >= seen — an entry below seen is provably unreachable
|
||||
// (see the header proof). Remaining entries drain at setActive(false) / terminate()
|
||||
// when process is guaranteed stopped.
|
||||
// REQUIRES reloadMutex_ held. Shared by reloadInstrument and rebuildVoiceEngine — the
|
||||
// one safety-critical swap dance (see the header's drain-slot proof).
|
||||
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
||||
graveyard_.erase(
|
||||
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
||||
@@ -302,10 +259,9 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||
// OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract:
|
||||
// a voice-param change touches NO audio data, so this rebuilds the engine
|
||||
// around a COPY of the live instrument's already-decoded keymap — no bridge, no disk —
|
||||
// and publishes through the same drain-slot swap, so ringing tails survive.
|
||||
// Off the audio thread. A voice-param change touches no audio data, so this rebuilds
|
||||
// the engine around a copy of the live instrument's already-decoded keymap — no
|
||||
// bridge, no disk — and publishes through the same drain-slot swap.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
LoadedInstrument* cur = live_.load(std::memory_order_acquire);
|
||||
if (!cur) return; // nothing loaded: the new params bake into the next real reload.
|
||||
@@ -321,13 +277,14 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||
}
|
||||
|
||||
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
// Same Preserve-window derivation as reloadInstrument (kPreserveWindowMs at the host rate).
|
||||
// Same Preserve-window derivation as reloadInstrument.
|
||||
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
|
||||
// Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap
|
||||
// is immutable after construction, and under reloadMutex_ nobody can free `cur`.
|
||||
// Deep-copy the decoded PCM + zones: safe to read concurrently with process() because
|
||||
// the keymap is immutable after construction and reloadMutex_ prevents `cur` from
|
||||
// being freed.
|
||||
Keymap km = cur->keymap;
|
||||
auto built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
@@ -336,23 +293,19 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::retireIdleDrain() {
|
||||
// Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or
|
||||
// it still sounds" — the common case costs one relaxed load and no mutex.
|
||||
// Cheap early-out before the lock: 0 means "no drain, or it still sounds".
|
||||
const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire);
|
||||
if (idleGen == 0) return;
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
LoadedInstrument* drain = draining_.load(std::memory_order_acquire);
|
||||
// Retire ONLY if the publication names the drain currently in the slot. A stale value
|
||||
// (about an already-evicted, older drain) can never match the newer occupant's
|
||||
// installedAt — the slot is monotone in generation — so a mid-swap race is closed by
|
||||
// this identity check, not by timing.
|
||||
// Retire only if the publication names the drain currently in the slot — a stale value
|
||||
// (an already-evicted, older drain) can never match the newer occupant's installedAt
|
||||
// (monotone in generation), closing a mid-swap race by identity rather than timing.
|
||||
if (!drain || drain->installedAt != idleGen) return;
|
||||
draining_.store(nullptr, std::memory_order_release);
|
||||
graveyard_.push_back(std::unique_ptr<LoadedInstrument>(drain));
|
||||
// Prune what is now provably unreachable — the same monotone-generation proof as the
|
||||
// reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be
|
||||
// held by process() now or ever again. The just-parked drain frees here immediately when
|
||||
// process() has already published past it; otherwise on the next reload/retire/deactivate.
|
||||
// Prune what is now provably unreachable (same monotone-generation proof as
|
||||
// reloadInstrument's reclaim).
|
||||
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
||||
graveyard_.erase(
|
||||
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
||||
@@ -363,18 +316,16 @@ void ReaSamplerProcessor::retireIdleDrain() {
|
||||
}
|
||||
|
||||
bool ReaSamplerProcessor::legacyLiftShouldRun() {
|
||||
// #A terminating guard for the pre-v10 legacy lift. The caller has already established
|
||||
// refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before
|
||||
// paying for a full reload. Once concluded, the steady state is this one relaxed load —
|
||||
// no bank read, no parse, no reload churn.
|
||||
// Terminating guard for the pre-v10 legacy lift (caller has already established
|
||||
// refs-empty + intent). Once concluded, the steady state is one relaxed load — no bank
|
||||
// read, no parse, no reload churn.
|
||||
if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
|
||||
const LegacyLiftDecision decision = legacyLiftDecision(
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey),
|
||||
referencedSampleIds(selectedSampleId(), performanceMap()));
|
||||
if (decision == LegacyLiftDecision::Stale) {
|
||||
// Provably stale (the bank parses and knows none of the referenced ids): give up
|
||||
// PERMANENTLY. A later bank change that re-introduces an id bumps the generation,
|
||||
// and the genChanged reload refreshes the refs without consulting this latch.
|
||||
// Provably stale: give up permanently. A later bank change that re-introduces an
|
||||
// id bumps the generation, and genChanged refreshes the refs without this latch.
|
||||
legacyLiftConcluded_.store(true, std::memory_order_relaxed);
|
||||
return false;
|
||||
}
|
||||
@@ -383,21 +334,18 @@ bool ReaSamplerProcessor::legacyLiftShouldRun() {
|
||||
|
||||
ReaSamplerProcessor::BankSyncResult
|
||||
ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
// OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call
|
||||
// REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER
|
||||
// host, or before connect) yields nullopt for both reads, so this no-ops cleanly.
|
||||
// Off the audio thread (editor's UI timer only). A disconnected bridge yields nullopt
|
||||
// for both reads, so this no-ops cleanly.
|
||||
BankSyncResult result;
|
||||
|
||||
// Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer
|
||||
// cadence that drives reloads — an edited-away instrument stops costing memory as soon
|
||||
// as its tails die instead of squatting in the drain slot until the next reload.
|
||||
// Park an idle drain snapshot in the graveyard on the same cadence that drives
|
||||
// reloads, so an edited-away instrument stops costing memory as soon as tails die.
|
||||
retireIdleDrain();
|
||||
|
||||
// --- S8: assignment-request consume FIRST -------------------------------------
|
||||
// Decode the pending assignment request (nullopt when absent/malformed). Resolve its
|
||||
// (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when
|
||||
// the sampleId names an existing sample (the reader requirement — an unresolvable pair is
|
||||
// dropped). Then run the pure consume decision against this instance's persisted marker.
|
||||
// --- Assignment-request consume first -------------------------------------------
|
||||
// Decodes the pending assignment request (nullopt if absent/malformed), resolves its
|
||||
// sampleId against the live bank blob (an unresolvable pair is dropped), then runs the
|
||||
// pure consume decision against this instance's persisted marker.
|
||||
std::optional<AssignmentRequest> request;
|
||||
if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) {
|
||||
request = decodeAssignmentRequest(*raw);
|
||||
@@ -405,26 +353,24 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
|
||||
bool resolves = false;
|
||||
if (request) {
|
||||
// Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request
|
||||
// whose sample was rolled back by an extension undo resolves to nullopt -> dropped).
|
||||
// Resolve against the CURRENT bank blob (a fresh read, so a request whose sample
|
||||
// was rolled back by an extension undo resolves to nullopt -> dropped).
|
||||
if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) {
|
||||
resolves = selectSample(*banksJson, request->sampleId).has_value();
|
||||
}
|
||||
}
|
||||
|
||||
// Read lastConsumed and conditionally write it back under a single lock scope so there
|
||||
// is no interleave window between the read and the write (a concurrent getState could
|
||||
// otherwise observe a stale marker between the two separate lock acquisitions).
|
||||
// Read + conditionally write lastConsumed under one lock scope so a concurrent
|
||||
// getState cannot observe a stale marker between two separate acquisitions.
|
||||
std::int64_t lastConsumed = 0;
|
||||
const AssignConsumeDecision decision = [&] {
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
lastConsumed = lastConsumedAssignGeneration_;
|
||||
const AssignConsumeDecision d =
|
||||
consumeDecision(request, lastConsumed, resolves, isFocusedTarget);
|
||||
// Advance the persisted consumed marker whenever the decision consumed the request
|
||||
// (applied OR dropped-as-seen). getState will persist it on the next project save so
|
||||
// a re-open does not re-apply. A non-target instance leaves the marker (decision
|
||||
// returns it unchanged) so it stays eligible if focus later lands here.
|
||||
// Advance the persisted marker whenever the decision consumed the request
|
||||
// (applied or dropped-as-seen); a non-target instance leaves it unchanged so it
|
||||
// stays eligible if focus later lands here.
|
||||
if (d.consumedGeneration != lastConsumed) {
|
||||
lastConsumedAssignGeneration_ = d.consumedGeneration;
|
||||
}
|
||||
@@ -432,13 +378,12 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
}();
|
||||
|
||||
if (decision.apply) {
|
||||
// Apply the assignment as this instance's own selection (the same path a user card-pick
|
||||
// takes) — the instrument updates its OWN state, never the bank. reloadInstrument below
|
||||
// rebuilds against the new selection, so skip a redundant reload here.
|
||||
// Apply as this instance's own selection (the instrument updates its own state,
|
||||
// never the bank); reloadInstrument below rebuilds against it.
|
||||
setSelectedSampleId(decision.sampleId);
|
||||
// Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone
|
||||
// materialized for the previously loaded sample would shadow the assigned pick under
|
||||
// first-match resolve. Authored maps (any narrow key range) are untouched.
|
||||
// Peer of the editor's Browse Load: a stale full-range zone from the previous
|
||||
// sample would shadow the assigned pick under first-match resolve. Authored maps
|
||||
// (narrow key ranges) are untouched.
|
||||
PerformanceMap reconciled = performanceMap();
|
||||
if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) {
|
||||
setPerformanceMap(reconciled);
|
||||
@@ -446,13 +391,10 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
result.applied = true;
|
||||
}
|
||||
|
||||
// --- S9: bank-generation change-detection -------------------------------------
|
||||
// Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll
|
||||
// (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload —
|
||||
// setState already loaded the instrument from its OWNED refs (pS), so a redundant reload
|
||||
// on open would only churn. A later
|
||||
// generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the
|
||||
// reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced).
|
||||
// --- Bank-generation change-detection -------------------------------------------
|
||||
// First poll (lastSeenBankGeneration_ == -1 sentinel) baselines without a reload —
|
||||
// setState already loaded from owned refs, so a redundant reload on open would only
|
||||
// churn. A later generation change (recapture/ingest/remove/undo) drives the reload.
|
||||
std::int64_t currentGen = kBankGenerationAbsent;
|
||||
if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) {
|
||||
currentGen = parseBankGeneration(*rawGen);
|
||||
@@ -462,18 +404,12 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
!firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen);
|
||||
lastSeenBankGeneration_ = currentGen;
|
||||
|
||||
// LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones)
|
||||
// but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had
|
||||
// nothing to decode unless the bank happened to be readable already. Reload on this
|
||||
// editor tick until the lift lands: reloadInstrument folds the bank blob into the refs
|
||||
// when readable, after which the table is non-empty and this never fires again (the
|
||||
// next save is then self-contained). A deliberately-empty instance has no intent and
|
||||
// never churns; a bank that is not readable YET retries a cheap null publish on the
|
||||
// editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob
|
||||
// PARSES and no referenced id resolves in it, the ids are provably stale — there is
|
||||
// nothing to lift, so the lift concludes permanently instead of churning a full bank
|
||||
// read + reload every tick forever. This is a MIGRATION convenience for old projects,
|
||||
// NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS).
|
||||
// Legacy lift (pre-v10 blob): restored state carries intent but no owned refs (old
|
||||
// blobs had no path table). Reload on this tick until reloadInstrument folds the bank
|
||||
// blob into the refs (after which this never fires again — the next save is
|
||||
// self-contained). legacyLiftShouldRun concludes permanently once the bank parses and
|
||||
// no referenced id resolves — a migration convenience only, never a playback
|
||||
// dependency (a v10 blob plays from its refs with no poll at all).
|
||||
bool legacyLift = false;
|
||||
if (!genChanged && !result.applied && sampleRefs().empty()) {
|
||||
const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty();
|
||||
@@ -481,10 +417,9 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
}
|
||||
|
||||
if (genChanged || result.applied || legacyLift) {
|
||||
reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard)
|
||||
// Report the reload distinctly from an S8 apply so the editor re-snapshots its bank
|
||||
// view. A legacy lift counts only when it actually landed an instrument (otherwise
|
||||
// every retry tick would churn the editor's caches for nothing).
|
||||
reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play
|
||||
// Reported distinctly from an applied assignment so the editor re-snapshots its
|
||||
// bank view; a legacy lift counts only when it actually landed an instrument.
|
||||
result.reloaded =
|
||||
genChanged ||
|
||||
(legacyLift && live_.load(std::memory_order_acquire) != nullptr);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// processor_state.cpp — the ReaSamplerProcessor's COMPONENT-STATE I/O (setState /
|
||||
// getState against the component_state_io codec) and its UI-thread parameter
|
||||
// accessors/setters (selection, performance map, channel mode, preview velocity,
|
||||
// voice-system params, master gain, preview-note mailbox posts). Split out of
|
||||
// reasampler_processor.cpp (Q-W2v, T4-12). Everything here runs OFF the audio
|
||||
// thread (UI / host load-save); the setters hand work to the reload family
|
||||
// (processor_reload.cpp) or store atomics process() picks up at block start.
|
||||
// processor_state.cpp — ReaSamplerProcessor's component-state I/O (setState/getState
|
||||
// against the component_state_io codec) and its UI-thread parameter accessors/setters
|
||||
// (selection, performance map, channel mode, preview velocity, voice-system params,
|
||||
// master gain, preview-note mailbox posts). Everything here runs off the audio thread;
|
||||
// setters hand work to the reload family (processor_reload.cpp) or store atomics
|
||||
// process() picks up at block start.
|
||||
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -14,8 +13,8 @@
|
||||
|
||||
#include "pluginterfaces/base/ibstream.h"
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp)
|
||||
#include "core/instrument/map/component_state_io.h" // the ComponentState codec (Q-W2v split)
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (post-mixer gain clamp)
|
||||
#include "core/instrument/map/component_state_io.h" // the ComponentState codec
|
||||
#include "core/instrument/map/sample_map.h" // reconcileSingleCaptureZones / retainRefs / referencedSampleIds
|
||||
|
||||
using namespace Steinberg;
|
||||
@@ -24,135 +23,107 @@ using namespace Steinberg::Vst;
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace instrument::map; // the codec + resolution vocabulary this TU marshals
|
||||
using instrument::engine::masterGainMaxLinear; // FB1 taper ceiling (Q-W6: shim retired)
|
||||
using instrument::engine::masterGainMaxLinear; // taper ceiling
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Read the whole component-state blob (the performance map, versioned). The blob is
|
||||
// small; read in one shot into a growable buffer.
|
||||
// The blob is small; read it in one shot into a growable buffer.
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::uint8_t chunk[256];
|
||||
int32 got = 0;
|
||||
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
|
||||
bytes.insert(bytes.end(), chunk, chunk + got);
|
||||
}
|
||||
// Component state (v3, S10) is {single-capture selection id, opt-in zones}. The
|
||||
// selection and the zones are DISTINCT — the default face is one picked capture, zones
|
||||
// are a demoted overlay — so both are restored explicitly (no more inferring a selection
|
||||
// from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only
|
||||
// blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so
|
||||
// the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10
|
||||
// silent empty state (no first-sample fallback in reloadInstrument).
|
||||
// Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at
|
||||
// the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing
|
||||
// before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a
|
||||
// pre-setup call would assert inside readZonesPayload (a programming error, not a field case).
|
||||
// Component state is {single-capture selection id, opt-in zones}, restored explicitly
|
||||
// since they're distinct (default face vs. a demoted overlay). deserializeComponentState
|
||||
// lifts older blobs cleanly (no first-sample fallback in reloadInstrument). sampleRate_
|
||||
// is the real host rate here — REAPER calls setupProcessing before setState on load.
|
||||
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
|
||||
setSelectedSampleId(cs.selectionId);
|
||||
// Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a
|
||||
// pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the
|
||||
// saved selection under first-match resolve. Reconciling here restores "the sample the
|
||||
// editor shows is the sample the engine plays" for already-affected projects; authored
|
||||
// Zone-view maps (any narrow key range) pass through untouched.
|
||||
// Heal-on-load: a blob saved under the pre-fix editor may carry stale full-range zones
|
||||
// (one per sample ever browsed), the oldest shadowing the saved selection under
|
||||
// first-match resolve. Authored Zone-view maps (narrow key ranges) pass through untouched.
|
||||
PerformanceMap restored = cs.map;
|
||||
reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadInstrument run unconditionally on load
|
||||
reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: reload below runs unconditionally
|
||||
setPerformanceMap(restored);
|
||||
// S8: restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||
// stale assign_request (the user may have manually changed the selection after the assign).
|
||||
// Restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||
// stale assign_request.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration;
|
||||
}
|
||||
// Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see
|
||||
// initialize) — the mode only governs how the reload below decodes, so no bus work here.
|
||||
// The output bus is fixed stereo (see initialize) — the mode only governs decode below.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
channelMode_ = cs.channelMode;
|
||||
channelModeExplicit_ = cs.channelModeExplicit;
|
||||
}
|
||||
// S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2
|
||||
// the editor's velocity knob is a concurrent UI-thread writer.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(previewMutex_);
|
||||
previewVelocity_ = cs.previewVelocity;
|
||||
}
|
||||
// Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly,
|
||||
// Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the
|
||||
// reload below so the rebuilt engine is born with the saved polyphony/mode.
|
||||
// Restore before the reload so the rebuilt engine is born with the saved polyphony/mode.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
voiceCount_ = cs.voiceCount;
|
||||
voiceMode_ = cs.voiceMode;
|
||||
monoTrigger_ = cs.monoTrigger;
|
||||
}
|
||||
// FB1: restore the post-mixer master gain (v8; older blobs lift to unity in
|
||||
// deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks
|
||||
// it up at the next block start.
|
||||
setMasterGainLinear(cs.masterGainLinear);
|
||||
// pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the
|
||||
// reload so it decodes straight from them — no bank read required to play. A pre-v10
|
||||
// blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob
|
||||
// becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift),
|
||||
// after which the next save is self-contained.
|
||||
// Restore the instance-owned sample refs before the reload so it decodes straight from
|
||||
// them — no bank read required. A pre-v10 blob lifts to an empty table; the reload
|
||||
// resolves nothing until the bank blob becomes readable (opportunistic refresh, or
|
||||
// pollBankSync's legacy lift), after which the next save is self-contained.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(refsMutex_);
|
||||
sampleRefs_ = cs.sampleRefs;
|
||||
}
|
||||
// pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty —
|
||||
// minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME
|
||||
// for the copy-collision analysis (the fresh nonce means this incarnation can never
|
||||
// be mistaken for the previous one's writes — or for a copy-sibling's).
|
||||
// Restore the publish identity (pre-v11 lifts to empty, minted on first publish).
|
||||
// usageNonce_ resets: a restored blob is a new lifetime, so this incarnation can never
|
||||
// be mistaken for the previous one's writes or a copy-sibling's.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(usageMutex_);
|
||||
instanceGuid_ = cs.instanceGuid;
|
||||
usageNonce_.clear();
|
||||
}
|
||||
// A new blob is new facts: a staleness proof latched against the PREVIOUS state does
|
||||
// not carry over (#A — the legacy lift gets one fresh run per restored state).
|
||||
// A new blob is new facts — the legacy lift gets one fresh run per restored state.
|
||||
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
|
||||
// Rebuild from the restored state (off-thread — setState is a load-time call).
|
||||
reloadInstrument();
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Persist the full instance state (v3, S10): the single-capture selection id AND the
|
||||
// opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler"
|
||||
// bank ext-state. An instance with no pick and no zones serializes to {"", no zones}
|
||||
// and restores as the S10 empty state (silence + "pick a capture"), never auto-playing
|
||||
// sample #1.
|
||||
// Persists the full instance state — never written to the "reasampler" bank ext-state.
|
||||
// No pick + no zones serializes to {"", no zones}, restoring as silence (never
|
||||
// auto-playing sample #1).
|
||||
ComponentState state_out;
|
||||
state_out.selectionId = selectedSampleId();
|
||||
state_out.map = performanceMap();
|
||||
{
|
||||
// S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9).
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
state_out.channelMode = channelMode_;
|
||||
state_out.channelModeExplicit = channelModeExplicit_;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
||||
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
|
||||
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_;
|
||||
}
|
||||
state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity
|
||||
state_out.previewVelocity = previewVelocity();
|
||||
{
|
||||
// Phase S: persist the voice-system parameters (component state v7).
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
state_out.voiceCount = voiceCount_;
|
||||
state_out.voiceMode = voiceMode_;
|
||||
state_out.monoTrigger = monoTrigger_;
|
||||
}
|
||||
state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8)
|
||||
// pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to
|
||||
// decode + play with no extension present. Filtered (on the snapshot copy, the member is
|
||||
// untouched) to exactly what the instance currently plays, so the table cannot grow with
|
||||
// browsing history.
|
||||
state_out.masterGainLinear = masterGainLinear();
|
||||
// Persist the owned sample refs — the saved blob decodes + plays with no extension
|
||||
// present. Filtered (snapshot copy only) to what the instance currently plays, so the
|
||||
// table cannot grow with browsing history.
|
||||
state_out.sampleRefs = sampleRefs();
|
||||
retainRefs(state_out.sampleRefs,
|
||||
referencedSampleIds(state_out.selectionId, state_out.map));
|
||||
// pS-usage: persist the publish identity (v11) so the instance's usage key is
|
||||
// stable across sessions (records do not proliferate per reopen).
|
||||
// Persist the publish identity so the usage key is stable across sessions.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(usageMutex_);
|
||||
state_out.instanceGuid = instanceGuid_;
|
||||
@@ -202,8 +173,7 @@ std::uint8_t ReaSamplerProcessor::previewVelocity() {
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) {
|
||||
// Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview
|
||||
// strike must sound). The editor's knob maps its 0..1 domain into this range before calling.
|
||||
// Clamp to [1,127] — 0 would be a note-off by convention, and a preview strike must sound.
|
||||
if (velocity < 1) velocity = 1;
|
||||
if (velocity > 127) velocity = 127;
|
||||
std::lock_guard<std::mutex> lock(previewMutex_);
|
||||
@@ -216,8 +186,8 @@ int ReaSamplerProcessor::voiceCount() {
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setVoiceCount(int count) {
|
||||
// Clamp to the shared pure-core range so the engine, the state bytes, and the editor's
|
||||
// control can never disagree about the legal polyphony span.
|
||||
// Clamp to the shared pure-core range so the engine, state bytes, and editor control
|
||||
// can never disagree about the legal polyphony span.
|
||||
if (count < kMinVoiceCount) count = kMinVoiceCount;
|
||||
if (count > kMaxVoiceCount) count = kMaxVoiceCount;
|
||||
{
|
||||
@@ -225,11 +195,8 @@ void ReaSamplerProcessor::setVoiceCount(int count) {
|
||||
if (voiceCount_ == count) return; // no-op: don't churn a rebuild
|
||||
voiceCount_ = count;
|
||||
}
|
||||
// LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from
|
||||
// the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change
|
||||
// touches no audio data) and the displaced instrument keeps rendering its ringing tails,
|
||||
// so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every
|
||||
// zone from disk. Same contract for the mode/trigger setters below.
|
||||
// Light rebuild through the drain-slot swap (no bridge re-read, no WAV re-decode) so a
|
||||
// voice-param change never cuts a sounding tail. Same contract below.
|
||||
rebuildVoiceEngine();
|
||||
}
|
||||
|
||||
@@ -262,9 +229,8 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
|
||||
// Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap =
|
||||
// +24 dB). One relaxed atomic store — the audio thread reads it at the next block start;
|
||||
// no rebuild, no lock (a post-sum output trim is not a keymap fact).
|
||||
// Clamp to the master_gain taper (0 = silence, cap = +24 dB). One relaxed atomic
|
||||
// store — no rebuild, no lock (a post-sum trim is not a keymap fact).
|
||||
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
|
||||
const double maxLin = masterGainMaxLinear();
|
||||
if (linear > maxLin) linear = maxLin;
|
||||
@@ -275,9 +241,8 @@ void ReaSamplerProcessor::previewNoteOn(int note) {
|
||||
if (note < 0) note = 0;
|
||||
if (note > 127) note = 127;
|
||||
const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request
|
||||
// Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as
|
||||
// long as we never land back on the exact value the audio thread last consumed in one step —
|
||||
// 16 bits gives 65535 posts between collisions, unreachable at UI-click rates).
|
||||
// Advance the sequence (wrapping; process compares for inequality — 16 bits gives 65535
|
||||
// posts between collisions, unreachable at UI-click rates).
|
||||
const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_;
|
||||
const std::uint32_t packed = (static_cast<std::uint32_t>(seq) << 16) |
|
||||
(static_cast<std::uint32_t>(vel) << 8) |
|
||||
@@ -297,15 +262,14 @@ void ReaSamplerProcessor::previewNoteOff(int note) {
|
||||
void ReaSamplerProcessor::setChannelMode(ChannelMode mode) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
// The editor toggle is a DELIBERATE choice either way: latch explicit even on a
|
||||
// same-mode click (the user confirmed the mode; the GA auto-default stops fighting it).
|
||||
// A deliberate choice either way: latch explicit even on a same-mode click so
|
||||
// auto-default stops fighting it.
|
||||
channelModeExplicit_ = true;
|
||||
if (channelMode_ == mode) return; // no decode change: don't churn a reload
|
||||
channelMode_ = mode;
|
||||
}
|
||||
// The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no
|
||||
// restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread
|
||||
// (mono = downmix, stereo = L/R split) and the RT path just keeps rendering.
|
||||
// The output bus is fixed stereo (no bus repoint): reloading re-decodes off-thread
|
||||
// under the new mode and the RT path just keeps rendering.
|
||||
reloadInstrument();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,41 +5,33 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/map/bridge_marshal.h"
|
||||
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04 grow-loop policy)
|
||||
#include "core/capture/capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation)
|
||||
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (grow-loop policy)
|
||||
#include "core/capture/capture_paths.h" // projectDirOfRpp (shared project-dir derivation)
|
||||
#include "ext_keys.h" // kProjExtNamespace (shared wire contract)
|
||||
|
||||
// The VST3 base types must be included before REAPER's VST3 interface header, which
|
||||
// uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from
|
||||
// pluginterfaces/base — all in namespace Steinberg.
|
||||
// VST3 base types must be included before REAPER's VST3 interface header, which uses
|
||||
// unqualified Steinberg types (FUnknown, CStringA, uint32, DECLARE_CLASS_IID, PLUGIN_API).
|
||||
#include "pluginterfaces/base/funknown.h"
|
||||
#include "pluginterfaces/base/ftypes.h"
|
||||
|
||||
// REAPER's VST3-side bridge interface (vendored). IReaperHostApplication is what REAPER
|
||||
// passes (as an IHostApplication) to IComponent::initialize; it exposes getReaperApi
|
||||
// (resolve-by-name) and getReaperParent (host context). The header uses UNQUALIFIED
|
||||
// Steinberg types (FUnknown, CStringA, uint32, FUID, DECLARE_CLASS_IID, PLUGIN_API), so
|
||||
// it must be pulled into the Steinberg namespace — the same way REAPER's own VST3
|
||||
// examples include it.
|
||||
// REAPER's VST3-side bridge interface (vendored): IReaperHostApplication is the
|
||||
// IHostApplication REAPER passes to IComponent::initialize, exposing getReaperApi
|
||||
// (resolve-by-name) and getReaperParent (host context). Pulled into namespace Steinberg
|
||||
// (the header's unqualified types), the same way REAPER's own VST3 examples include it.
|
||||
namespace Steinberg {
|
||||
#include "reaper_vst3_interfaces.h"
|
||||
} // namespace Steinberg
|
||||
|
||||
// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperHostApplication::iid; some
|
||||
// TU must DEFINE it. We do it here — this is the only place that queries for the
|
||||
// interface (FUnknownPtr uses the iid), so the definition lives with its sole use.
|
||||
// DECLARE_CLASS_IID in the REAPER header only declares the iid; this is the only TU that
|
||||
// queries for the interface, so the DEFINE lives with its sole use.
|
||||
DEF_CLASS_IID(Steinberg::IReaperHostApplication)
|
||||
|
||||
// The ext-state namespace is the SHARED wire contract between the extension (writer)
|
||||
// and this instrument (reader); it lives in ext_keys.h (pure, REAPER-free) —
|
||||
// reasampler::kProjExtNamespace() — so the two artifacts read one symbol and cannot
|
||||
// drift. Channel-derived (Phase V, V4): the accessor returns "reasampler" (stable) or
|
||||
// "reasampler_beta" (beta), matching whatever the extension wrote. The S1 spike
|
||||
// duplicated it locally; that duplication is retired.
|
||||
// The ext-state namespace is the shared wire contract with the extension — ext_keys.h's
|
||||
// kProjExtNamespace() (pure, REAPER-free), channel-derived so both artifacts read one
|
||||
// symbol and cannot drift.
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
|
||||
using capture::projectDirOfRpp;
|
||||
using instrument::map::decodeGetProjExtState;
|
||||
|
||||
@@ -53,27 +45,24 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
||||
hostApp_ = nullptr;
|
||||
if (!context) return false;
|
||||
|
||||
// Query the host context for REAPER's bridge interface. In a non-REAPER host this
|
||||
// query fails and we stay unconnected — the instrument still loads.
|
||||
// In a non-REAPER host this query fails and we stay unconnected — the instrument
|
||||
// still loads.
|
||||
Steinberg::FUnknownPtr<Steinberg::IReaperHostApplication> reaper(context);
|
||||
if (!reaper) return false;
|
||||
hostApp_ = reaper.get();
|
||||
|
||||
// Resolve the ext-state functions by name. getReaperApi returns the same function
|
||||
// pointers the extension resolves via rec->GetFunc; a null return means the symbol
|
||||
// is unavailable (very old REAPER) — degrade gracefully.
|
||||
// getReaperApi returns the same function pointers the extension resolves via
|
||||
// rec->GetFunc; a null return means the symbol is unavailable (very old REAPER).
|
||||
getProjExtState_ = reinterpret_cast<GetProjExtStateFn>(
|
||||
reaper->getReaperApi("GetProjExtState"));
|
||||
enumProjExtState_ = reinterpret_cast<EnumProjExtStateFn>(
|
||||
reaper->getReaperApi("EnumProjExtState"));
|
||||
// EnumProjects(-1, ...) yields the active project AND its .rpp path — the same call
|
||||
// the persist shell (ext_state_io.cpp) uses, so the instrument derives the project
|
||||
// directory identically.
|
||||
// EnumProjects(-1, ...) yields the active project + its .rpp path — same convention
|
||||
// the persist shell uses, so the instrument derives the project directory identically.
|
||||
enumProjects_ = reinterpret_cast<EnumProjectsFn>(
|
||||
reaper->getReaperApi("EnumProjects"));
|
||||
// pS-usage: the (prefix-guarded) usage publish write + the track-identity pair the
|
||||
// usage record stamps. All degrade to null gracefully — an old REAPER just never
|
||||
// publishes usage (the extension then protects by bank references only).
|
||||
// The (prefix-guarded) usage publish write + the track-identity pair it stamps. All
|
||||
// degrade to null gracefully — an old REAPER never publishes usage.
|
||||
setProjExtState_ = reinterpret_cast<SetProjExtStateFn>(
|
||||
reaper->getReaperApi("SetProjExtState"));
|
||||
getTrackGuid_ = reinterpret_cast<GetTrackGuidFn>(
|
||||
@@ -87,23 +76,16 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
||||
std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::string& key) {
|
||||
if (!getProjExtState_ || !hostApp_) return std::nullopt;
|
||||
|
||||
// Fetch the host project (getReaperParent(3) — project). Reads that live "reasampler"
|
||||
// ext-state against the ACTIVE project the instrument was instantiated in, so it
|
||||
// follows project switches for free (D6).
|
||||
// getReaperParent(3) reads the live "reasampler" ext-state against the active project
|
||||
// the instrument was instantiated in, so it follows project switches for free. A null
|
||||
// project is legitimate (REAPER treats it as the current project) — pass it through
|
||||
// rather than bailing; a fruitless read still yields nullopt to the caller.
|
||||
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
|
||||
void* proj = reaper->getReaperParent(3);
|
||||
// A null project is legitimate (e.g. instantiated before a project context exists);
|
||||
// REAPER treats null as the current project for these calls, so we pass it through
|
||||
// rather than bailing — but if the read yields nothing the caller sees nullopt.
|
||||
|
||||
// GetProjExtState writes into a caller buffer; the bank blob can be large (many
|
||||
// samples), so grow the buffer until the value fits rather than risk a silent
|
||||
// truncation. The retry policy is the SHARED pure wire::readProjExtStateGrowing
|
||||
// (T2-04 — one loop for the
|
||||
// extension's persist/usage reads and this bridge read; the rules cannot drift):
|
||||
// absent (rv <= 0) and the >16 MB ceiling both fold to nullopt here, and a
|
||||
// complete value still runs through decodeGetProjExtState (the stale/empty-buffer
|
||||
// guard) exactly as before.
|
||||
// The bank blob can be large, so grow the buffer until it fits rather than risk a
|
||||
// silent truncation. The shared wire::readProjExtStateGrowing loop keeps this bridge
|
||||
// read and the extension's persist/usage reads from drifting.
|
||||
const auto read = wire::readProjExtStateGrowing(
|
||||
[&](char* buf, int cap) {
|
||||
return getProjExtState_(proj, kProjExtNamespace(), key.c_str(), buf, cap);
|
||||
@@ -116,24 +98,21 @@ std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::strin
|
||||
bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
|
||||
const std::string& value) {
|
||||
if (!setProjExtState_ || !hostApp_) return false;
|
||||
// STRUCTURAL read-only-bank guard: this module writes usage keys and nothing else.
|
||||
// A non-"rsusage_" key is a programming error upstream — refuse rather than widen
|
||||
// the instrument's write surface (banks/view/tail/assign stay extension-owned).
|
||||
// Read-only-bank guard: this module writes usage keys and nothing else. A non-
|
||||
// "rsusage_" key is refused rather than widening the instrument's write surface
|
||||
// (banks/view/tail/assign stay extension-owned).
|
||||
const std::string prefix = kProjExtUsageKeyPrefix;
|
||||
if (usageKey.compare(0, prefix.size(), prefix) != 0) return false;
|
||||
|
||||
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
|
||||
void* proj = reaper->getReaperParent(3); // null = current project (same as reads)
|
||||
// SetProjExtState returns "the size of the state for this extname" (SDK ~6288) —
|
||||
// after storing our non-empty value the namespace state is necessarily > 0, so a
|
||||
// <= 0 return means the write did not land. Reported to the caller (the publish
|
||||
// path retries on the next reload tick); a silently-dropped record would leave the
|
||||
// instance's holds unprotected.
|
||||
// SetProjExtState returns the size of the extname's state — after storing a
|
||||
// non-empty value that's necessarily > 0, so <= 0 means the write did not land (the
|
||||
// publish path retries next reload tick; a silent drop would leave holds unprotected).
|
||||
const int rv =
|
||||
setProjExtState_(proj, kProjExtNamespace(), usageKey.c_str(), value.c_str());
|
||||
// Deliberately NO MarkProjectDirty: a usage change always accompanies a component-
|
||||
// state change that already dirties the project; an idempotent load-time republish
|
||||
// must not flag an untouched project as modified.
|
||||
// Deliberately NO MarkProjectDirty: a usage change always rides a component-state
|
||||
// change that already dirties the project.
|
||||
return rv > 0;
|
||||
}
|
||||
|
||||
@@ -151,11 +130,8 @@ std::string ReaperBridge::currentTrackGuid() {
|
||||
|
||||
std::string ReaperBridge::activeProjectDir() {
|
||||
if (!enumProjects_) return {};
|
||||
// idx=-1 is the current project tab; the out-buffer receives the full .rpp path,
|
||||
// EMPTY for a never-saved project. Same call + convention as the persist shell; the pure
|
||||
// projectDirOfRpp turns the .rpp path into the project directory (parent, forward-
|
||||
// slashed) and keeps an unsaved project's empty path empty (no default-location
|
||||
// fallback — the tool's invariant).
|
||||
// idx=-1 is the current project tab; the out-buffer is empty for a never-saved
|
||||
// project. projectDirOfRpp keeps that empty (no default-location fallback).
|
||||
std::vector<char> buf(4096, '\0');
|
||||
enumProjects_(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return projectDirOfRpp(std::string(buf.data()));
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
// reaper_bridge.h — the REAPER VST-host bridge (Phase S1 read spike). THIN shell:
|
||||
// resolves REAPER API functions by name over the host context and reads the live
|
||||
// "reasampler" project ext-state. The fiddly decode lives in bridge_marshal (pure).
|
||||
// reaper_bridge.h — the REAPER VST-host bridge. Thin shell: resolves REAPER API functions
|
||||
// by name over the host context and reads the live "reasampler" project ext-state. The
|
||||
// fiddly decode lives in bridge_marshal (pure).
|
||||
//
|
||||
// VERIFIED BRIDGE MECHANISM (corrects §1a's estimate). §1a described the VST2-style
|
||||
// hostcb opcode pattern (hostcb(&effect, 0xdeadbeef, 0xdeadf00d, ...)). That is the
|
||||
// VST2 path (video_processor.h documents it for a VST2 aEffect). For a VST3 plugin the
|
||||
// bridge is exposed differently and more cleanly: REAPER passes an IHostApplication as
|
||||
// the `context` to IComponent::initialize(FUnknown* context); querying it for
|
||||
// IReaperHostApplication (vendor/reaper-sdk/sdk/reaper_vst3_interfaces.h) yields:
|
||||
// * getReaperApi(funcname) -> resolve a REAPER API function pointer by name
|
||||
// (the VST3 equivalent of opcode 0xdeadf00d), and
|
||||
// * getReaperParent(3) -> the host ReaProject* (the VST3 equivalent of the
|
||||
// 0xdeadf00e host-context fetch; 1=track, 2=take, 3=project, 4=fxdsp, 5=trackchan).
|
||||
// So a VST3 uses IReaperHostApplication, not the raw hostcb opcodes. Verified against
|
||||
// reaper_vst3_interfaces.h + reaper_plugin_functions.h at the spike.
|
||||
// Bridge mechanism: REAPER passes an IHostApplication as `context` to
|
||||
// IComponent::initialize; querying it for IReaperHostApplication yields getReaperApi
|
||||
// (resolve a REAPER API function pointer by name) and getReaperParent(3) (the host
|
||||
// ReaProject*; 1=track, 2=take, 3=project, 4=fxdsp, 5=trackchan) — not VST2 hostcb opcodes.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -32,48 +24,39 @@ class ReaperBridge {
|
||||
public:
|
||||
ReaperBridge() = default;
|
||||
|
||||
// Bind to the host. `context` is the FUnknown* REAPER hands IComponent::initialize.
|
||||
// Returns true when the REAPER bridge is available (host is REAPER and the ext-state
|
||||
// API resolved). Safe to call with a null or non-REAPER context — returns false.
|
||||
// Binds to the host (`context` is the FUnknown* IComponent::initialize hands us).
|
||||
// Returns true when the host is REAPER and the ext-state API resolved; safe to call
|
||||
// with a null or non-REAPER context (returns false).
|
||||
bool connect(Steinberg::FUnknown* context);
|
||||
|
||||
// True once connect() found the REAPER host application AND resolved the ext-state
|
||||
// functions.
|
||||
bool isConnected() const { return getProjExtState_ != nullptr; }
|
||||
|
||||
// Read a "reasampler" ext-state value by key from the host's active project.
|
||||
// Returns nullopt when unconnected, when the project can't be resolved, or when the
|
||||
// key is absent. This is the S1 read-spike entry point.
|
||||
// Reads a "reasampler" ext-state value by key from the host's active project.
|
||||
// Returns nullopt when unconnected, unresolvable, or the key is absent.
|
||||
//
|
||||
// NOT REAL-TIME SAFE (it allocates a read buffer and calls into REAPER): callers on
|
||||
// the audio thread MUST NOT invoke it. The S4 instrument reads on the main/UI thread
|
||||
// and hands a snapshot to the process path (see reasampler_processor.cpp).
|
||||
// NOT REAL-TIME SAFE (allocates + calls into REAPER): audio-thread callers MUST NOT
|
||||
// invoke this. The instrument reads on the main/UI thread and hands a snapshot to
|
||||
// the process path.
|
||||
std::optional<std::string> readReasamplerExtState(const std::string& key);
|
||||
|
||||
// The active project's directory (the folder holding its .rpp), forward-slashed,
|
||||
// no trailing slash — the M4 convention persist uses to place the bank alongside
|
||||
// the .rpp. Empty for an unsaved project or when unconnected. The instrument
|
||||
// resolves relative sample paths against this the SAME way persist does
|
||||
// (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe.
|
||||
// The active project's directory (forward-slashed, no trailing slash) — the same
|
||||
// convention persist uses to place the bank alongside the .rpp. Empty for an unsaved
|
||||
// project or when unconnected. Not RT-safe.
|
||||
std::string activeProjectDir();
|
||||
|
||||
// Write THIS INSTANCE's usage record (pS-usage): the ONE sanctioned instrument-side
|
||||
// ext-state write. `usageKey` MUST carry the "rsusage_" prefix (ext_keys.h's
|
||||
// usageKeyFor) — any other key is REFUSED here, so the read-only-BANK invariant is
|
||||
// enforced structurally: this module can publish the instance's own usage and
|
||||
// nothing else (banks/view/tail/assign remain unwritable from the instrument).
|
||||
// Returns true iff written (the SetProjExtState return is checked — a dropped
|
||||
// write must not silently claim protection). NOT RT-safe (calls into REAPER) —
|
||||
// publish sites are the off-audio-thread reload path only. Deliberately does NOT
|
||||
// mark the project dirty: a usage change always rides a component-state change
|
||||
// that already does.
|
||||
// Writes THIS INSTANCE's usage record: the ONE sanctioned instrument-side ext-state
|
||||
// write. `usageKey` MUST carry the "rsusage_" prefix (ext_keys.h's usageKeyFor); any
|
||||
// other key is refused, enforcing the read-only-bank invariant structurally (banks/
|
||||
// view/tail/assign stay unwritable from the instrument). Returns true iff written
|
||||
// (the SetProjExtState return is checked). NOT RT-safe — publish sites are the
|
||||
// off-audio-thread reload path only. Deliberately does NOT mark the project dirty: a
|
||||
// usage change always rides a component-state change that already does.
|
||||
bool writeUsageExtState(const std::string& usageKey, const std::string& value);
|
||||
|
||||
// The canonical "{XXXXXXXX-...}" GUID string of the track hosting this FX instance
|
||||
// (getReaperParent(1) -> GetTrackGUID -> guidToString — the same rendering as the
|
||||
// extension's track_guid::guidString, so usage records and the extension's live-FX
|
||||
// enumeration compare byte-equal). Empty when unconnected or no track context (the
|
||||
// usage reader then falls back to any-instance liveness — fail-safe). Not RT-safe.
|
||||
// The canonical GUID string of the track hosting this FX instance (same rendering as
|
||||
// the extension's track_guid::guidString, so usage records compare byte-equal
|
||||
// against its live-FX enumeration). Empty when unconnected or no track context (the
|
||||
// usage reader then falls back to any-instance liveness). Not RT-safe.
|
||||
std::string currentTrackGuid();
|
||||
|
||||
private:
|
||||
@@ -84,18 +67,14 @@ private:
|
||||
using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx,
|
||||
char* keyOut, int keyOut_sz, char* valOut,
|
||||
int valOut_sz);
|
||||
// EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path (SDK line
|
||||
// ~1264). The instrument uses idx=-1 (current tab) so it follows the active project,
|
||||
// and reads the .rpp path from the out-buffer exactly as the persist shell
|
||||
// (ext_state_io.cpp) does.
|
||||
// EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path. idx=-1 (current
|
||||
// tab) follows the active project, same convention as the persist shell.
|
||||
using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz);
|
||||
// SetProjExtState(proj, extname, key, value) -> int (SDK line ~6290). Used ONLY by
|
||||
// writeUsageExtState (prefix-guarded) — see the read-only-bank note there.
|
||||
// Used ONLY by writeUsageExtState (prefix-guarded) — see the read-only-bank note there.
|
||||
using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key,
|
||||
const char* value);
|
||||
// GetTrackGUID(MediaTrack*) -> GUID* (SDK ~3562) + guidToString(const GUID*, char*
|
||||
// destNeed64) (SDK ~3848). Both held as opaque-pointer signatures so the header
|
||||
// stays SDK-type-free; the GUID* is passed straight through, never dereferenced here.
|
||||
// Opaque-pointer signatures so the header stays SDK-type-free; the GUID* is passed
|
||||
// straight through, never dereferenced here.
|
||||
using GetTrackGuidFn = void* (*)(void* tr);
|
||||
using GuidToStringFn = void (*)(const void* g, char* destNeed64);
|
||||
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
// reasampler_editor.h — the VST3 IPlugView LICE editor for the ReaSampler 9000
|
||||
// capture-first UI (Phase S10). THIN shell: hosts a LICE-drawn child window inside the
|
||||
// host's IPlugView seat and routes host paint/mouse into the pure geometry modules
|
||||
// (capture_browser, keyboard_strip) + the pure mapping (sample_map). Windows-only (D5).
|
||||
//
|
||||
// The default face is the CAPTURE BROWSER: a bank-filter tab strip over a grid of
|
||||
// scannable capture cards (peak thumbnail + name + root/key badge). A fresh instance with
|
||||
// no pick shows a "pick a capture" EMPTY STATE and plays silence (the S10 policy reversal
|
||||
// of the S4 first-sample auto-play). Picking a card loads that one capture and reveals a
|
||||
// guided SINGLE-CAPTURE SETUP surface (a keyboard strip with the capture's root marker +
|
||||
// a level readout). Multi-zone keymap editing is a demoted, opt-in ZONES panel (S10-Z),
|
||||
// reached by a toggle and driven by the same keyboard_strip drag machine.
|
||||
//
|
||||
// All layout/hit-test/drag math lives in the pure modules; this shell only draws + routes
|
||||
// (a LICE_SysBitmap blitted in WM_PAINT, a WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP
|
||||
// drag-state machine hit-testing via the pure resolvers). Peak thumbnails are computed
|
||||
// shell-side from the decoded WAV (bank_model's Sample carries no envelope) and cached —
|
||||
// the mirror of bank_panel::thumbnailFor. Every edit commits OFF the audio thread via the
|
||||
// processor's reloadInstrument (RT path untouched).
|
||||
//
|
||||
// Subclasses CPluginView for the IPlugView boilerplate; overrides the attach/remove hooks
|
||||
// to create/destroy the child window and onSize to resize it.
|
||||
// reasampler_editor.h — VST3 IPlugView LICE editor for the ReaSampler 9000 UI. Thin shell:
|
||||
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules
|
||||
// (capture_browser, keyboard_strip, sample_map) — default face is the capture browser, then
|
||||
// single-capture setup, with an opt-in zones panel. All layout/hit-test/drag math lives in
|
||||
// the pure modules; every edit commits off the audio thread via reloadInstrument.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -30,13 +13,13 @@
|
||||
|
||||
#include "public.sdk/source/common/pluginview.h"
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules)
|
||||
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit)
|
||||
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (r11 knob deck — Sample FB1, Zone FB2)
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type)
|
||||
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit)
|
||||
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (envelope overlay draw seam)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (Sample + Zone knob deck)
|
||||
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
|
||||
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state)
|
||||
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state)
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
@@ -46,10 +29,6 @@ class LICE_IBitmap; // fwd: the paint helpers take one; lice.h is included only
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim
|
||||
// is retired from the editor family; engine symbols — ChannelMode, VoiceMode, MonoTrigger,
|
||||
// the voice-count constants, VelocityCurve via the engine re-export — stay in flat
|
||||
// `reasampler` and resolve via the enclosing namespace).
|
||||
using audio::AudioSample;
|
||||
using audio::Envelope;
|
||||
using instrument::map::BankChoice;
|
||||
@@ -69,9 +48,9 @@ class ReaSamplerProcessor;
|
||||
|
||||
class ReaSamplerEditor : public Steinberg::CPluginView {
|
||||
public:
|
||||
// `processor` owns this editor's lifetime domain and outlives it; the editor reads the
|
||||
// live bank through it and drives selection/zone edits + reload on user input. May be
|
||||
// null (defensive — a real host always supplies one).
|
||||
// `processor` outlives this editor; the editor reads the live bank through it and drives
|
||||
// selection/zone edits + reload on user input. May be null (defensive; a real host always
|
||||
// supplies one).
|
||||
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
||||
~ReaSamplerEditor() override;
|
||||
|
||||
@@ -86,67 +65,51 @@ protected:
|
||||
Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;
|
||||
|
||||
private:
|
||||
// Which face the editor shows (S-VIEW-1, three-view model). Sample is the HOME/default
|
||||
// face (the loaded capture). Browse is a full-window MODAL picker overlaid on Sample
|
||||
// (select + confirm/cancel changes the loaded capture, then dismisses). Zone is the
|
||||
// dedicated multi-zone keymap surface, button-summoned. All three draw over the same
|
||||
// snapshotted bank; Browse + Zone return to Sample when dismissed.
|
||||
// Sample is the home/default face. Browse is a full-window modal picker overlaid on
|
||||
// Sample. Zone is the dedicated multi-zone keymap surface, button-summoned.
|
||||
enum class View { kSample, kBrowse, kZone };
|
||||
|
||||
// What a mouse drag is currently editing (the drag-state machine). kNone = no drag in
|
||||
// flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the
|
||||
// single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop
|
||||
// marker on the S11 waveform surface (which marker is in waveMarker_); kEnvNode is a
|
||||
// draggable envelope breakpoint on the Sample-view hero overlay (S-VIEW-3, which node in
|
||||
// envNode_); kCurveNode is a draggable velocity-curve control point in the S-VIEW-10
|
||||
// transfer-curve editor (which point in curvePointIndex_); kDeckKnob is a GRAB-ANCHORED
|
||||
// vertical radial-knob drag on an r11 knob deck — the Sample face's deck/cluster (FB1)
|
||||
// or the Zone panel's per-zone deck (FB2) — (which control in dragParamId_; the value at
|
||||
// grab in dragKnobStartValue_ — no jump on grab, FA4).
|
||||
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their
|
||||
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
|
||||
// knob drag (control in dragParamId_, grab value in dragKnobStartValue_).
|
||||
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
|
||||
kScrollThumb, kEnvNode, kCurveNode, kDeckKnob };
|
||||
|
||||
// The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces).
|
||||
// The int value is the opaque control id the pure knob_deck hit-test returns; the shell
|
||||
// maps it to the picked zone's play params (or a processor-side per-instance setter).
|
||||
// Controls on the setup surface. The int value is the opaque control id the pure
|
||||
// knob_deck hit-test returns; the shell maps it to the zone's play params or a
|
||||
// processor-side per-instance setter.
|
||||
enum class ParamControl {
|
||||
kPlayMode = 0, // Gate | Trigger toggle (S15)
|
||||
kPitchEngine, // Varispeed | Preserve toggle (S16)
|
||||
kPlayMode = 0, // Gate | Trigger toggle
|
||||
kPitchEngine, // Varispeed | Preserve toggle
|
||||
kAttack, // AHDSR attack (Gate) / —
|
||||
kHold, // AHDSR hold (Gate, S15)
|
||||
kHold, // AHDSR hold (Gate)
|
||||
kDecay, // AHDSR decay (Gate)
|
||||
kSustain, // AHDSR sustain (Gate)
|
||||
kRelease, // AHDSR release (Gate)
|
||||
kTrigLength, // Trigger %-length (Trigger, S15)
|
||||
kTrigFadeIn, // Trigger fade-in (Trigger, S15)
|
||||
kTrigFadeOut, // Trigger fade-out (Trigger, S15)
|
||||
kPitchEnvEnable, // AD pitch envelope on|off (S16)
|
||||
kPitchEnvAttack, // AD pitch attack (S16)
|
||||
kPitchEnvDecay, // AD pitch decay (S16)
|
||||
kPitchEnvDepth, // AD pitch depth in +/- semitones (S16)
|
||||
kKeyTrack, // S-VIEW-6 key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds)
|
||||
// r11 deck-only controls (FB1): processor-side per-instance params, NOT zone params —
|
||||
// routed to the processor setters, never through applyZoneControl / the map.
|
||||
kVoiceCount, // Phase S polyphony bound (1..32) — a stepped knob in the VOICE group
|
||||
kTrigLength, // Trigger %-length
|
||||
kTrigFadeIn, // Trigger fade-in
|
||||
kTrigFadeOut, // Trigger fade-out
|
||||
kPitchEnvEnable, // AD pitch envelope on|off
|
||||
kPitchEnvAttack, // AD pitch attack
|
||||
kPitchEnvDecay, // AD pitch decay
|
||||
kPitchEnvDepth, // AD pitch depth in +/- semitones
|
||||
kKeyTrack, // key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds)
|
||||
// Deck-only controls: processor-side per-instance params, NOT zone params — routed to
|
||||
// the processor setters, never through applyZoneControl / the map.
|
||||
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
|
||||
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
|
||||
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
|
||||
kMasterGain, // FB1 post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
|
||||
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
|
||||
kCount
|
||||
};
|
||||
|
||||
// The waveform markers on the single-capture setup surface (S11). Order is the draw + hit
|
||||
// order (start first). Named generically per the spec so S15 can repurpose the surface with
|
||||
// a different marker set; here it is start-point + the sustain loop's two ends.
|
||||
// The waveform markers on the single-capture setup surface: start-point + the sustain
|
||||
// loop's two ends, in draw + hit order.
|
||||
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 };
|
||||
|
||||
// --- Hover model (Phase L, L3) ------------------------------------------------
|
||||
//
|
||||
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE so the kit
|
||||
// draws its hover state on that element only ("hover on every interactive element" +
|
||||
// "sub-frame feedback = the perception of speed", §3.3/§3.5). Cleared to kNone on
|
||||
// WM_MOUSELEAVE (tracked via TrackMouseEvent). `index` disambiguates within a kind
|
||||
// (tab ordinal, visible-card index, control-row id); -1 when not applicable. Mirror of
|
||||
// bank_panel's L2 hover model.
|
||||
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index`
|
||||
// disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1 when
|
||||
// not applicable.
|
||||
enum class HoverKind {
|
||||
kNone,
|
||||
kNavBrowse, // the Sample-view "Browse" title-band button (opens the Browse modal)
|
||||
@@ -163,10 +126,10 @@ private:
|
||||
kAddZone, // the "+ Add Zone" button
|
||||
kDeleteZone, // the "Delete" zone button
|
||||
kControl, // a knob-deck element (index = control id)
|
||||
kCurveNode, // a velocity-curve control point (index = point index, S-VIEW-10)
|
||||
kVelKnob, // the cluster preview-velocity radial knob (r11)
|
||||
kCurveButton, // the cluster mini curve-preview button (r11 — opens the popup)
|
||||
kPopupClose, // the curve popup's Close (x) button (r11)
|
||||
kCurveNode, // a velocity-curve control point (index = point index)
|
||||
kVelKnob, // the cluster preview-velocity radial knob
|
||||
kCurveButton, // the cluster mini curve-preview button (opens the popup)
|
||||
kPopupClose, // the curve popup's Close (x) button
|
||||
};
|
||||
struct HoverTarget {
|
||||
HoverKind kind = HoverKind::kNone;
|
||||
@@ -177,96 +140,72 @@ private:
|
||||
|
||||
#ifdef _WIN32
|
||||
void paint(HDC hdc);
|
||||
void paintSample(LICE_IBitmap* bmp, int w, int h); // S-VIEW-2/r11 home face
|
||||
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // S-VIEW-5 modal picker overlay
|
||||
void paintZone(LICE_IBitmap* bmp, int w, int h); // S-VIEW-8 zone surface
|
||||
void paintSample(LICE_IBitmap* bmp, int w, int h); // home face
|
||||
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // modal picker overlay
|
||||
void paintZone(LICE_IBitmap* bmp, int w, int h); // zone surface
|
||||
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
|
||||
|
||||
// --- r11 knob-deck rendering (FB1 Sample face; FB2 Zone panel) -------------------
|
||||
// The knob deck: the fenced task groups drawn through the L1 kit — group fence + caption +
|
||||
// compact caption toggles + radial knobs (param_slider's FA4 primitive) with label<->value
|
||||
// swap on hover/drag. `descs` picks the group set: the full Sample deck (deckGroupDescs)
|
||||
// or the Zone panel's per-zone groups (zoneDeckGroupDescs). Lays out from deckArea's
|
||||
// top-left; the caller anchors (Sample bottom-anchors, Zone top-anchors).
|
||||
// The knob deck: group fence + caption + compact caption toggles + radial knobs with
|
||||
// label<->value swap on hover/drag. `descs` picks the group set (Sample's deckGroupDescs
|
||||
// or the Zone panel's zoneDeckGroupDescs); caller anchors (Sample bottom, Zone top).
|
||||
void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone,
|
||||
const std::vector<DeckGroupDesc>& descs);
|
||||
// The mini curve-preview button (shared by the Sample cluster + the Zone panel, FB2): a
|
||||
// hairline bg/cell square tracing the zone's live curve; Active border while the popup is up.
|
||||
// The mini curve-preview button shared by the Sample cluster + the Zone panel.
|
||||
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
|
||||
// The centered curve-popup sheet (wash + title + close + full-size curve editor). Edits
|
||||
// popupZone() — the Sample face's one-zone site or the Zone surface's selected zone (FB2).
|
||||
// The centered curve-popup sheet. Edits popupZone() — the Sample face's one-zone site
|
||||
// or the Zone surface's selected zone.
|
||||
void paintCurvePopup(LICE_IBitmap* bmp, int w, int h);
|
||||
// Trace the S-VIEW-3 amp-envelope overlay + its draggable node handles over `waveArea` for
|
||||
// `zone`'s play params, at the sample's wall-clock duration. Shared by the Sample hero band.
|
||||
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`.
|
||||
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone,
|
||||
std::int64_t frames);
|
||||
// S-VIEW-10: the velocity->amp transfer-curve editor — a bordered box (X = velocity 0-127,
|
||||
// Y = amp 0-1), the monotone spline traced by eval, one draggable node handle per control
|
||||
// point. Since FB2 its ONLY host is the r11 popup sheet (both surfaces summon it via the
|
||||
// mini preview button); all mapping / hit-test / clamp math lives in the pure
|
||||
// velocity_curve module. `r` empty -> draws nothing.
|
||||
// The velocity->amp transfer-curve editor (X = velocity 0-127, Y = amp 0-1); its only
|
||||
// host is the popup sheet. `r` empty -> draws nothing.
|
||||
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
|
||||
|
||||
// Route a mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab
|
||||
// starts a kCurveNode drag; Alt-click on an interior node deletes it (committed at once);
|
||||
// an empty-space click ADDS a point at the cursor and grabs it for an immediate drag.
|
||||
// `zoneIndex` must be a valid index into map_.zones (callers materialize first).
|
||||
// Mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab
|
||||
// starts a kCurveNode drag; Alt-click on an interior node deletes it at once; an
|
||||
// empty-space click adds a point and grabs it. `zoneIndex` must be valid (callers
|
||||
// materialize first).
|
||||
void handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y);
|
||||
|
||||
// Route a left-click while the curve popup is open (the popup is MODAL over the Sample
|
||||
// face AND the Zone surface, FB2): Close / outside-wash dismiss, in-box clicks into the
|
||||
// shared curve machinery against popupZoneIndex(), everything else on the sheet swallowed.
|
||||
// Returns true when the popup consumed the click (i.e. whenever it is open).
|
||||
// Left-click while the curve popup is open (modal over both faces): Close /
|
||||
// outside-wash dismiss, in-box clicks route to the curve machinery, else swallowed.
|
||||
// Returns true whenever the popup is open (it consumed the click).
|
||||
bool handlePopupMouseDown(int w, int h, int x, int y);
|
||||
|
||||
void onMouseDown(int x, int y);
|
||||
// The Browse-modal and Zone-surface halves of the mouse-down dispatch (Q-W2v: the
|
||||
// input TUs split along the face axis — onMouseDown keeps the Sample-face branch and
|
||||
// delegates these two; bodies in editor_input_browse_zone.cpp). Behavior-identical
|
||||
// to the former inline branches.
|
||||
// The Browse-modal and Zone-surface halves of the mouse-down dispatch (bodies in
|
||||
// editor_input_browse_zone.cpp).
|
||||
void mouseDownBrowse(int w, int h, int x, int y);
|
||||
void mouseDownZone(int w, int h, int x, int y);
|
||||
void onMouseMove(int x, int y);
|
||||
void onMouseUp(int x, int y);
|
||||
// r11: right-click — the curve popup's PRIMARY node-delete affordance (issue 3c). Only
|
||||
// acts while the popup is open (over the Sample face OR the Zone surface, FB2); a
|
||||
// right-click on a popup curve node deletes it through the same commit path as Alt-click
|
||||
// (deletePoint's endpoint guard makes endpoint right-clicks a safe no-op). Everything
|
||||
// else ignores right-clicks.
|
||||
// Right-click is the curve popup's primary node-delete affordance; only acts while the
|
||||
// popup is open (deletePoint's endpoint guard makes an endpoint right-click a no-op).
|
||||
void onMouseRDown(int x, int y);
|
||||
|
||||
// Apply a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: routes ordinary
|
||||
// controls through applyControl against the zone's play struct, and kKeyTrack against the
|
||||
// zone's keyTrack scalar (0..200% over the knob's 0..1). Used by both the click + drag paths.
|
||||
// Applies a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: ordinary
|
||||
// controls route through applyControl; kKeyTrack writes the zone's keyTrack scalar
|
||||
// (0..200% over the knob's 0..1).
|
||||
void applyZoneControl(int zoneIndex, int id, double value, int segment);
|
||||
|
||||
// Resolve the interactive element under (x, y) into hover_ (Phase L, L3). Called from
|
||||
// WM_MOUSEMOVE (also while a drag is in flight — the resolved element just isn't used
|
||||
// for a hover repaint mid-drag). Repaints only when the hovered element changed, so an
|
||||
// idle mouse-move is free. Windows-only (the hit-tests use the shell's Win32 client rect).
|
||||
// Resolves the interactive element under (x, y) into hover_, called from WM_MOUSEMOVE.
|
||||
// Repaints only on change, so an idle move is free. Windows-only.
|
||||
void resolveHover(int x, int y);
|
||||
// True iff element (kind, index) is the live hover_ target — the shell maps this to the
|
||||
// kit's Hover interaction state when the element has no more-specific state (Active, etc.).
|
||||
bool isHovered(HoverKind kind, int index) const {
|
||||
return hover_.kind == kind && hover_.index == index;
|
||||
}
|
||||
void onMouseWheel(int delta); // S12 browser scroll (wheel)
|
||||
void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke
|
||||
void onMouseWheel(int delta); // browser scroll (wheel)
|
||||
void onSearchChar(unsigned int ch); // type-to-filter search keystroke
|
||||
|
||||
// S13 (relay degraded): an OS file drop landed on the editor window. We do NOT ingest (the
|
||||
// instrument is a read-only bank consumer and the relay is unshipped) — we flash the "drop
|
||||
// on the ReaSampler panel to add" affordance so the drop is never silently swallowed and the
|
||||
// shipped ingest gesture stays discoverable. `droppedCount` is how many files were dropped
|
||||
// (drawn into the banner). NEVER inserts a timeline item / never touches the bank.
|
||||
// An OS file drop landed on the editor window. We do NOT ingest (read-only bank
|
||||
// consumer) — flash a "drop on the ReaSampler panel to add" affordance instead of
|
||||
// silently swallowing it. Never inserts a timeline item.
|
||||
void onFilesDropped(int droppedCount);
|
||||
|
||||
// The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the
|
||||
// audio thread). Polls the processor's bank-sync (generation change -> hands-free reload;
|
||||
// a new assignment request -> apply as this instance's selection) and, when anything
|
||||
// changed, re-snapshots the editor's own view (refreshFromBank) + repaints so the browser /
|
||||
// setup surface reflect the new bank. An open editor means THIS instance is the focused
|
||||
// assignment target (the thundering-herd policy — see the handoff), so it passes true.
|
||||
// Suppressed WHILE A DRAG IS IN FLIGHT so a mid-drag reload does not yank the edit surface.
|
||||
// The change-detection tick (WM_TIMER, UI thread only): polls the processor's bank-sync
|
||||
// and re-snapshots + repaints when anything changed. Suppressed mid-drag so a reload
|
||||
// never yanks the edit surface.
|
||||
void onSyncTimer();
|
||||
|
||||
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||
@@ -279,37 +218,31 @@ private:
|
||||
// selection + performance map. Main/UI thread only. Called on attach and after any edit.
|
||||
void refreshFromBank();
|
||||
|
||||
// Publish the edited zones/selection to the processor, then rebuild the instrument OFF
|
||||
// the audio thread. UI thread only. One place so every edit commits identically.
|
||||
// Publishes the edited zones/selection to the processor, then rebuilds the instrument
|
||||
// off the audio thread. UI thread only.
|
||||
void commitAndReload();
|
||||
|
||||
// Commit `id` as the loaded single-capture selection (the Browse Load confirm and the
|
||||
// double-click accelerator both route here). Runs reconcileSingleCaptureZones first so
|
||||
// the previous sample's materialized full-range zone cannot linger and shadow the new
|
||||
// pick under first-match resolve (the zone-bleed fix, issue 3a), then publishes + reloads.
|
||||
// Commits `id` as the loaded single-capture selection. Runs reconcileSingleCaptureZones
|
||||
// first so the previous sample's materialized full-range zone cannot linger and shadow
|
||||
// the new pick under first-match resolve, then publishes + reloads.
|
||||
void loadSelection(const std::string& id);
|
||||
|
||||
// Recompute the capture cards visible under the current bank filter (samples_ narrowed by
|
||||
// activeFilterBankId_; "" = All) into visible_. Called on refresh + filter change.
|
||||
// Recomputes the visible capture cards (samples_ narrowed by activeFilterBankId_ then
|
||||
// search) into visible_. Called on refresh + filter change.
|
||||
void rebuildVisible();
|
||||
|
||||
// The peak thumbnail for a bank sample id at `binCount` bins, computed once from the
|
||||
// decoded WAV (mirror of bank_panel::thumbnailFor) and cached by (id, binCount). Returns
|
||||
// an empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O).
|
||||
// The peak thumbnail for a bank sample id at `binCount` bins, cached by (id, binCount).
|
||||
// Empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O).
|
||||
const Envelope& thumbnailFor(const std::string& sampleId, int binCount);
|
||||
|
||||
// The decoded MONO PCM for a bank sample id, decoded once from the WAV and cached by id.
|
||||
// Feeds the S11 waveform surface: the full-res envelope binned at view width AND the
|
||||
// zero-crossing snap (both need the raw frames, not the binned thumbnail). Returns an empty
|
||||
// vector when the WAV can't be resolved/decoded. UI thread only (file I/O). Reuses the same
|
||||
// decode path as thumbnailFor (no new WAV reader), keyed by id (not width — snap is width-
|
||||
// independent). Cleared with the thumbnail cache on refresh.
|
||||
// The decoded mono PCM for a bank sample id, cached by id — feeds both the binned
|
||||
// waveform envelope and the zero-crossing snap. Empty vector on decode failure. UI
|
||||
// thread only (file I/O); cleared with the thumbnail cache on refresh.
|
||||
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
|
||||
|
||||
// The effective loop + start markers for the picked single capture (S11): the per-zone
|
||||
// OVERRIDE for the picked id when one exists in map_, else the bank's S2 loop intrinsic
|
||||
// (loop) / frame 0 (start). Absent loop -> loopStart==loopEnd==0 (the "no loop" state).
|
||||
// frames is the decoded length (for defaulting loopEnd when the bank left the loop empty).
|
||||
// The effective loop + start markers for the picked capture: the per-zone override when
|
||||
// one exists in map_, else the bank's loop intrinsic / frame 0. Absent loop ->
|
||||
// loopStart==loopEnd==0. `frames` defaults loopEnd when the bank left the loop empty.
|
||||
struct SetupMarkers {
|
||||
std::int64_t start = 0;
|
||||
std::int64_t loopStart = 0;
|
||||
@@ -318,125 +251,93 @@ private:
|
||||
};
|
||||
SetupMarkers pickedMarkers(std::int64_t frames) const;
|
||||
|
||||
// Commit an edited marker set for the picked capture as a per-zone loop/start override
|
||||
// (upsert on the picked id — mirror of the root-marker path), then reload off-thread.
|
||||
// Commits an edited marker set for the picked capture as a per-zone loop/start override
|
||||
// (upsert on the picked id), then reloads off-thread.
|
||||
void commitPickedMarkers(const SetupMarkers& m);
|
||||
|
||||
// Write `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append).
|
||||
// Does NOT call commitAndReload — callers decide whether this is a live-drag update or a
|
||||
// final commit. selectedId_ must be non-empty before calling. Returns the zone index
|
||||
// (0-based) that was updated or appended, so callers can set selectedZone_.
|
||||
// Writes `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append).
|
||||
// Does NOT call commitAndReload — callers decide live-drag vs final commit. selectedId_
|
||||
// must be non-empty. Returns the updated/appended zone index.
|
||||
int upsertPickedOverride(const SetupMarkers& m);
|
||||
|
||||
// --- S12/S15/S16 parameter value domains (both deck surfaces) ------------------
|
||||
//
|
||||
// The deck knobs edit a zone's ZonePlaySeconds (S15 play mode + AHDSR; S16 pitch engine +
|
||||
// AD pitch envelope). Wall-clock times are SECONDS (rate-free); the keymap build resolves
|
||||
// them to frames at the live rate. Instrument-owned (D-B), never a bank fact.
|
||||
// Deck knobs edit a zone's ZonePlaySeconds (play mode + AHDSR; pitch engine + AD pitch
|
||||
// envelope) — wall-clock seconds, rate-free; the keymap build resolves to frames.
|
||||
|
||||
// The normalized [0,1] display value for control `id` given `play` (the shell's domain
|
||||
// mapping: seconds->0..1 over a fixed seconds ceiling, sustain 0..1 as-is, %-length/fade
|
||||
// frames->0..1, semitone depth centered at 0.5).
|
||||
// The normalized [0,1] display value for control `id` given `play` (seconds -> 0..1 over
|
||||
// a fixed ceiling, sustain 0..1 as-is, %-length/fade frames -> 0..1, semitone depth
|
||||
// centered at 0.5).
|
||||
double controlValue(int id, const ZonePlaySeconds& play) const;
|
||||
|
||||
// Apply a committed control interaction to `play`: a knob's normalized `value` (mapped back
|
||||
// into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place.
|
||||
// Applies a committed control interaction to `play`: a knob's normalized `value` or a
|
||||
// toggle's `segment` (0/1). Mutates `play` in place.
|
||||
void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
|
||||
|
||||
// The Trigger fade-in/out knob full-scale, in SOURCE frames: kFadeMaxSeconds (2 s
|
||||
// wall-clock) resolved against the live rate at use (Q-W0 T3-03 — never a baked-in
|
||||
// rate). 44.1 kHz fallback before setupProcessing has run. Storage stays frames.
|
||||
// The Trigger fade-in/out knob full-scale, in source frames: kFadeMaxSeconds resolved
|
||||
// against the live rate — never a baked-in rate. 44.1 kHz fallback pre-setupProcessing.
|
||||
double fadeMaxFrames() const;
|
||||
|
||||
// --- S-VIEW-3 envelope overlay seam (frames <-> fraction converter) ----------
|
||||
//
|
||||
// envelope_overlay's AmpEnvelope is a DERIVED VIEW, not a TriggerParams copy: it stores the
|
||||
// Trigger fades as FRACTIONS of the played span, while the zone stores them as SOURCE FRAMES.
|
||||
// These two members own the non-trivial conversion on BOTH paths (documented in
|
||||
// envelope_overlay.h's TRIGGER SEAM note). `frames` is the sample's total source frame count;
|
||||
// `rate` is the live sample rate (the wall-clock AHDSR seconds are rate-free and copy 1-to-1,
|
||||
// but the Trigger played-span math needs the frame count).
|
||||
// envelope_overlay's AmpEnvelope stores Trigger fades as fractions of the played span,
|
||||
// while the zone stores source frames — pack/unpack own that conversion (see
|
||||
// envelope_overlay.h's trigger-seam note). `frames` is total source frames; AHDSR
|
||||
// seconds are rate-free and copy 1-to-1.
|
||||
|
||||
// PACK (draw): zone play params -> AmpEnvelope. Copies AHDSR seconds directly; derives the
|
||||
// Trigger fade fractions from the source-frame fades over the played span.
|
||||
// `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)).
|
||||
// PACK (draw): zone play params -> AmpEnvelope. `startFrame` is the zone's effective
|
||||
// start point (zone.startPoint.value_or(0)).
|
||||
AmpEnvelope packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
|
||||
std::int64_t startFrame) const;
|
||||
|
||||
// UNPACK (commit): an edited AmpEnvelope -> the zone's play params. Copies AHDSR seconds
|
||||
// directly; converts the Trigger fade fractions back to source frames over the played span.
|
||||
// `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)).
|
||||
// Mutates `play` in place; only the mode-relevant fields are written.
|
||||
// UNPACK (commit): an edited AmpEnvelope -> the zone's play params, in place.
|
||||
void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame,
|
||||
ZonePlaySeconds& play) const;
|
||||
|
||||
// The clamp bounds envelope_edit uses, matching the control-panel sliders' own domains (so a
|
||||
// node drag can never produce a param a slider couldn't — the S-VIEW-F2 invariant).
|
||||
// Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can
|
||||
// never produce a param a slider couldn't.
|
||||
EnvClampBounds envClampBounds() const;
|
||||
|
||||
// --- Sample-view resolution helpers (the ONE storage site, S15-F2) -----------
|
||||
//
|
||||
// The single-capture Sample face reads/writes the same one-zone map site as the Zone surface.
|
||||
// These resolve the effective values for the picked id: effectiveSampleZone returns the picked
|
||||
// id's one-zone override (found in map_) or a product-default PerformanceZone bound to the
|
||||
// picked id (not yet materialized — a control edit materializes it, mirroring the Zone path).
|
||||
// The Sample face and the Zone surface read/write the same one-zone map site.
|
||||
// effectiveSampleZone returns the picked id's override if present in map_, else a
|
||||
// product-default zone (not yet materialized — a control edit does that).
|
||||
PerformanceZone effectiveSampleZone() const;
|
||||
// The effective root: the picked id's rootOverride, else its bank intrinsic, else middle C.
|
||||
// The effective root: rootOverride, else the bank intrinsic, else middle C.
|
||||
int effectiveRoot() const;
|
||||
// The live sample rate from the bridge (for the envelope overlay's seconds<->frames time base),
|
||||
// or 0 when unavailable (the caller guards). Matches the voice engine's resolution rate.
|
||||
// The live sample rate from the bridge, or 0 when unavailable (caller guards).
|
||||
double liveSampleRate() const;
|
||||
// The persisted preview velocity as a 0..1 slider value (MIDI 1..127 mapped onto [0,1]).
|
||||
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
|
||||
double previewVelocity01() const;
|
||||
|
||||
// Find-or-materialize the one-zone override for the picked id and return a mutable index into
|
||||
// map_.zones (appending a product-default zone if none exists). selectedId_ must be non-empty.
|
||||
// The mirror of upsertPickedOverride for a control edit — used when a Sample-face control edit
|
||||
// needs a concrete zone to write. Returns -1 if selectedId_ is empty.
|
||||
// Find-or-materializes the one-zone override for the picked id, appending a
|
||||
// product-default zone if none exists. Mirror of upsertPickedOverride for a control
|
||||
// edit. Returns -1 if selectedId_ is empty.
|
||||
int ensureSampleZone();
|
||||
|
||||
// --- Curve-popup target resolution (r11 FB1 + FB2) -----------------------------
|
||||
//
|
||||
// The popup edits ONE zone per open: the Zone surface's SELECTED zone (FB2) or the Sample
|
||||
// face's picked one-zone site. popupZone is the read-only resolve (paint/hover/right-click
|
||||
// hit-test); popupZoneIndex is the edit target — it materializes the Sample-face zone via
|
||||
// ensureSampleZone but NEVER materializes on the Zone surface (the button only shows for
|
||||
// an explicit selection). Returns -1 when there is no valid target (callers guard).
|
||||
// The popup edits ONE zone per open: the Zone surface's selected zone or the Sample
|
||||
// face's picked site. popupZone is the read-only resolve; popupZoneIndex is the edit
|
||||
// target — materializes on the Sample face via ensureSampleZone, never on the Zone
|
||||
// surface (button only shows for an explicit selection). -1 = no valid target.
|
||||
PerformanceZone popupZone() const;
|
||||
int popupZoneIndex();
|
||||
|
||||
// --- r11 knob-deck plumbing (FB1 Sample face; FB2 Zone panel) -------------------
|
||||
//
|
||||
// The deck is the r11 replacement for the slider control strips on BOTH surfaces: the pure
|
||||
// knob_deck module lays out the fenced groups, param_slider's FA4 primitive owns the
|
||||
// value<->needle map, and these members own the control-id <-> value binding.
|
||||
|
||||
// The PER-ZONE deck groups (FB2 — the set both surfaces share): AMP ENVELOPE (Gate:
|
||||
// A/H/D/S/R; Trigger: Fade In / Length % / Fade Out + two RESERVED blanks so a mode flip
|
||||
// never reflows the neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth).
|
||||
// The Zone panel renders exactly these — per-instance state stays off it.
|
||||
// The per-zone deck groups both surfaces share: AMP ENVELOPE (Gate A/H/D/S/R; Trigger
|
||||
// Fade In/Length %/Fade Out + two reserved blanks so a mode flip never reflows
|
||||
// neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth).
|
||||
std::vector<DeckGroupDesc> zoneDeckGroupDescs(const ZonePlaySeconds& play) const;
|
||||
|
||||
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE (Voices
|
||||
// knob + Poly|Mono caption toggle + Retrig|Legato row toggle) and MASTER (the FB1
|
||||
// post-mixer Gain knob) groups.
|
||||
// The full Sample-face deck: the shared groups + the per-instance VOICE (Voices knob +
|
||||
// Poly|Mono + Retrig|Legato) and MASTER (Gain knob) groups.
|
||||
std::vector<DeckGroupDesc> deckGroupDescs(const ZonePlaySeconds& play) const;
|
||||
|
||||
// The normalized [0,1] value a deck knob shows for `zone` — zone params route through
|
||||
// controlValue/keyTrack; the processor-side ids (voice count, master gain, and the
|
||||
// cluster's preview velocity via the -2 sentinel) read the processor's live value, so
|
||||
// the knob and its storage are two views on one model (re-read each paint).
|
||||
// controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
|
||||
// via the -2 sentinel) read the processor's live value.
|
||||
double deckControlNorm(int id, const PerformanceZone& zone) const;
|
||||
|
||||
// Apply a deck-knob value: zone params write map_.zones[zoneIndex] (live-drag semantics,
|
||||
// commit on release); processor params (voice count / master gain / preview velocity)
|
||||
// write through the processor setters immediately (transient — no map edit, no reload).
|
||||
// zoneIndex is ignored for processor-side ids.
|
||||
// Applies a deck-knob value: zone params write map_.zones[zoneIndex] (commit on
|
||||
// release); processor params write through the processor setters immediately
|
||||
// (transient — no map edit, no reload). zoneIndex ignored for processor-side ids.
|
||||
void applyDeckKnob(int zoneIndex, int id, double norm);
|
||||
|
||||
// The knob's live value label (shown in place of the name label during hover/drag):
|
||||
// seconds ("0.123s"), percents ("85%"), source frames ("8820f"), signed semitones
|
||||
// ("+3.5st"), a voice count ("16"), or the master-gain dB ("-inf"/"+2.4dB").
|
||||
// The knob's live value label shown during hover/drag: seconds, percents, source
|
||||
// frames, signed semitones, a voice count, or the master-gain dB.
|
||||
std::string deckValueLabel(int id, const PerformanceZone& zone) const;
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
@@ -447,64 +348,51 @@ private:
|
||||
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
|
||||
std::string selectedId_; // the single-capture pick ("" = empty state)
|
||||
PerformanceMap map_; // the opt-in zones (empty = no zones)
|
||||
ChannelMode channelMode_ = ChannelMode::Mono; // S7 mono/stereo toggle snapshot
|
||||
ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot
|
||||
|
||||
// --- Phase S voice-deck snapshot (PROVISIONAL controls — the Wave B recompose owns the
|
||||
// final deck). Mirrors of the processor's persisted voice-system params, refreshed with
|
||||
// the rest of the live snapshot; every edit writes through the processor setters (which
|
||||
// rebuild the engine off-thread via the drain-slot swap).
|
||||
// Mirrors of the processor's persisted voice-system params, refreshed with the rest of the
|
||||
// live snapshot; every edit writes through the processor setters (which rebuild the engine
|
||||
// off-thread via the drain-slot swap).
|
||||
int voiceCount_ = kDefaultVoiceCount;
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
|
||||
// --- Transient UI state (not persisted; component state carries selection + zones) ---
|
||||
View view_ = View::kSample; // default face is the loaded-sample home (S-VIEW-1)
|
||||
// Transient UI state (not persisted; component state carries selection + zones).
|
||||
View view_ = View::kSample; // default face is the loaded-sample home
|
||||
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
||||
int selectedZone_ = -1; // highlighted zone in the Zone surface; -1 = none
|
||||
|
||||
// --- S-VIEW-5 Browse modal picker (a selection PENDING confirm) ---------------
|
||||
// The Browse overlay is a select-then-confirm picker: a click marks a pending pick without
|
||||
// loading it; Confirm (or double-click) commits it to selectedId_ + reloads and returns to
|
||||
// Sample; Cancel discards it and returns to Sample unchanged. "" = nothing picked yet.
|
||||
// The Browse overlay is a select-then-confirm picker: a click marks a pending pick;
|
||||
// Confirm/double-click commits it + reloads; Cancel discards it. "" = nothing picked.
|
||||
std::string browsePendingId_;
|
||||
int lastBrowseClickCard_ = -1; // for double-click-to-load detection (visible_ index)
|
||||
|
||||
// --- S-VIEW-4 preview-trigger note (transient) -------------------------------
|
||||
// The MIDI note the preview button is currently sounding (a held Gate voice), or -1 when the
|
||||
// button is up. Set on preview-button press (note-on posted to the processor), cleared on
|
||||
// release (note-off posted). One note at a time — a fresh press releases the prior.
|
||||
// The MIDI note the preview button is currently sounding (held Gate voice), or -1 when
|
||||
// up. One note at a time — a fresh press releases the prior.
|
||||
int previewingNote_ = -1;
|
||||
|
||||
// --- S13 drop-to-load affordance (relay DEGRADED — transient, never persisted) ----
|
||||
// S13's cross-artifact ingest relay (editor drop -> extension ingest) is NOT shipped: the
|
||||
// instrument's REAPER bridge is deliberately READ-ONLY (it never writes the bank / ext
|
||||
// state), so an editor drop cannot relay a bank-ingest request without a new write seam +
|
||||
// an extension-side poller (surfaced as a decision, not crossed here). The DEGRADE path per
|
||||
// the spec: the editor ACCEPTS the drop (WM_DROPFILES) and, rather than silently swallowing
|
||||
// it, flashes a clear affordance pointing at the shipped ingest gesture (drop onto the
|
||||
// docked ReaSampler panel). When > 0, the affordance banner is shown; each sync tick decays
|
||||
// it so it auto-dismisses. No file is ingested, no timeline item is ever inserted.
|
||||
// The editor-drop -> extension-ingest relay is not shipped (the bridge is read-only):
|
||||
// an OS drop just flashes a "drop on the panel instead" banner (dropHintTicks_ counts
|
||||
// down via the sync tick). Never ingests, never inserts a timeline item.
|
||||
int dropHintTicks_ = 0; // remaining sync ticks to show the drop affordance
|
||||
|
||||
// --- S12 browser scroll + search (transient UI state, never persisted) --------
|
||||
// Browser scroll + search (transient UI state, never persisted).
|
||||
int scrollOffset_ = 0; // vertical px offset into the card grid (clamped)
|
||||
std::string searchQuery_; // type-to-filter narrow; "" = no search
|
||||
bool searchFocused_ = false; // whether the search box has keyboard focus
|
||||
|
||||
// --- S12 numeric note entry (LICE text-entry idiom, transient) ----------------
|
||||
// When >= 0, a low/high/root field is being typed; entryText_ accumulates the keystrokes
|
||||
// and commits (parseNoteEntry) on Enter. -1 = no field editing. The field id is a
|
||||
// ParamControl-independent small enum encoded inline (see the .cpp: 0=low,1=high,2=root).
|
||||
// When >= 0, a low/high/root field is being typed (0=low,1=high,2=root); entryText_
|
||||
// accumulates keystrokes and commits via parseNoteEntry on Enter. -1 = no field editing.
|
||||
int entryField_ = -1;
|
||||
std::string entryText_;
|
||||
|
||||
// --- Hover state (Phase L, L3; transient, never persisted) --------------------
|
||||
// Hover state (transient, never persisted).
|
||||
HoverTarget hover_; // the interactive element under the pointer
|
||||
#ifdef _WIN32
|
||||
bool mouseTracking_ = false; // TrackMouseEvent armed for WM_MOUSELEAVE this "over" cycle
|
||||
#endif
|
||||
|
||||
// --- Drag-state machine ------------------------------------------------------
|
||||
// Drag-state machine.
|
||||
DragKind drag_ = DragKind::kNone;
|
||||
int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver
|
||||
int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag
|
||||
@@ -515,54 +403,46 @@ private:
|
||||
int dragStartRoot_ = 60;
|
||||
PerformanceMap dragStartMap_; // map_ snapshotted at grab; restored on capture-loss
|
||||
|
||||
// S11 waveform-marker drag: which marker + the marker set snapshotted at grab time (so the
|
||||
// pixel-delta resolver shifts the grabbed frame from its grab-time value, and inter-marker
|
||||
// clamps use the sibling markers).
|
||||
// Waveform-marker drag: which marker + the marker set snapshotted at grab time, so the
|
||||
// pixel-delta resolver shifts from the grab-time value and inter-marker clamps use the
|
||||
// sibling markers.
|
||||
WaveMarker waveMarker_ = WaveMarker::kStart;
|
||||
SetupMarkers dragStartMarkers_;
|
||||
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
|
||||
std::int64_t dragStartFrame_ = 0; // zone startPoint at grab time (0 if absent); for env-node drag
|
||||
|
||||
// S12 scrollbar-thumb drag: the offset held at grab time (the pixel-delta resolver shifts
|
||||
// from it). kDeckKnob drag: which control id + the zone it edits.
|
||||
// Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id + zone.
|
||||
int dragStartScrollOffset_ = 0;
|
||||
int dragParamId_ = -1; // control id under a kDeckKnob drag; -2 = preview-vel knob
|
||||
int dragParamZone_ = -1; // the zone index a kDeckKnob drag edits; -1 = processor-side
|
||||
|
||||
// S-VIEW-3 envelope-node drag: which node is grabbed + the AmpEnvelope snapshotted at grab
|
||||
// (so the pixel delta is absolute, per envelope_edit's grabEnv contract). The overlay rect +
|
||||
// sample frame count are re-derived at move time from the live Sample-view layout.
|
||||
// Envelope-node drag: which node + the AmpEnvelope snapshotted at grab (absolute-delta
|
||||
// contract, per envelope_edit's grabEnv).
|
||||
EnvNode envNode_ = EnvNode::Origin;
|
||||
AmpEnvelope dragStartEnv_{};
|
||||
|
||||
// S-VIEW-10 velocity-curve node drag: which point is grabbed, the curve snapshotted at grab
|
||||
// (resolvePointDrag's absolute-delta contract), the box rect the grab happened in (the Sample
|
||||
// and Zone views place the editor differently — the drag resolves against the grab-time box),
|
||||
// and which zone the edit lands on. Mirror of the envelope-node drag state.
|
||||
// Velocity-curve node drag: which point, the curve snapshotted at grab
|
||||
// (resolvePointDrag's absolute-delta contract), the grab-time box rect (Sample and Zone
|
||||
// place the editor differently), and which zone the edit lands on.
|
||||
int curvePointIndex_ = -1;
|
||||
VelocityCurve dragStartCurve_ = VelocityCurve::flat();
|
||||
Rect dragCurveRect_{};
|
||||
int dragCurveZone_ = -1;
|
||||
|
||||
// r11 deck-knob drag (FB1): the control's normalized value AT GRAB — knobDragValue maps
|
||||
// the vertical pixel delta from this anchor, so a grab never jumps the value (FA4).
|
||||
// Deck-knob drag: the normalized value at grab — knobDragValue maps the vertical pixel
|
||||
// delta from this anchor, so a grab never jumps the value.
|
||||
double dragKnobStartValue_ = 0.0;
|
||||
|
||||
// r11 curve popup (FB1 + FB2): open flag — editor-local, never persisted. The popup edits
|
||||
// popupZone() — the picked capture's one-zone site on the Sample face, the SELECTED zone
|
||||
// on the Zone surface — re-resolved each paint so a sync-tick refresh mid-open stays
|
||||
// coherent (a refresh that drops the target closes it; see refreshFromBank).
|
||||
// Curve popup open flag, never persisted. Edits popupZone(), re-resolved each paint so a
|
||||
// sync-tick refresh mid-open stays coherent (a refresh that drops the target closes it).
|
||||
bool curvePopupOpen_ = false;
|
||||
|
||||
// --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------
|
||||
// Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so
|
||||
// a bank edit (a re-captured or deleted sample) does not show a stale thumbnail.
|
||||
// Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize
|
||||
// recomputes at the new width. Cleared on refresh so a stale sample never shows.
|
||||
std::unordered_map<std::string, Envelope> thumbCache_;
|
||||
|
||||
// --- Decoded mono-PCM cache (S11; id -> full-res frames) ------------------------------
|
||||
// Keyed by id (width-independent, unlike thumbCache_). Feeds the waveform envelope binning
|
||||
// + the zero-crossing snap. Cleared alongside thumbCache_ on refresh so a re-captured or
|
||||
// deleted sample does not show/snap against stale PCM.
|
||||
// Decoded mono-PCM cache, keyed by id (width-independent). Feeds the waveform envelope
|
||||
// binning + zero-crossing snap. Cleared alongside thumbCache_ on refresh.
|
||||
std::unordered_map<std::string, std::vector<AudioSample>> pcmCache_;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
// reasampler_embed.cpp — see reasampler_embed.h. The IReaperUIEmbedInterface shell.
|
||||
// Windows-only (D5); guarded so a non-Windows build degrades to a stub that reports
|
||||
// "not supported" and draws nothing.
|
||||
// Windows-only; guarded so a non-Windows build degrades to a stub that reports "not
|
||||
// supported" and draws nothing.
|
||||
|
||||
#include "shell/instrument/reasampler_embed.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived embed label, S18)
|
||||
#include "core/instrument/map/bank_sync.h" // parseBankGeneration (S9 dirty-guard over the per-paint refresh)
|
||||
#include "core/ui/component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3)
|
||||
#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text (L3)
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived embed label)
|
||||
#include "core/instrument/map/bank_sync.h" // parseBankGeneration (dirty-guard over the per-paint refresh)
|
||||
#include "core/ui/component_geometry.h" // KitBox — the kit text/fill draw box
|
||||
#include "shell/panel/draw_kit.h" // the shared draw kit: fillSurface/text
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect (shared with embed_strip)
|
||||
#include "core/instrument/ui/embed_strip.h" // the pure strip layout + hit-test
|
||||
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey
|
||||
#include "shell/instrument/reaper_bridge.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
#include "core/ui/theme.h" // Role / InteractionState / spectralColor (L3)
|
||||
#include "core/ui/theme.h" // Role / InteractionState / spectralColor
|
||||
|
||||
// wdltypes.h first: it defines INT_PTR portably (and pulls <windows.h> on Windows), which
|
||||
// reaper_plugin_fx_embed.h's REAPER_FXEMBED_IBitmap::Extended needs as its return type.
|
||||
// wdltypes.h first: it defines INT_PTR portably (needed by REAPER_FXEMBED_IBitmap::Extended's
|
||||
// return type in the header below).
|
||||
#include "wdltypes.h"
|
||||
|
||||
// REAPER's embed message/bitmap contract (vendored). REAPER_FXEMBED_IBitmap is an alias of
|
||||
// LICE_IBitmap, and the WM_* / DrawInfo / SizeHints definitions live here.
|
||||
// REAPER's embed message/bitmap contract (vendored): REAPER_FXEMBED_IBitmap aliases
|
||||
// LICE_IBitmap; WM_* / DrawInfo / SizeHints live here.
|
||||
#include "reaper_plugin_fx_embed.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -34,17 +34,12 @@
|
||||
|
||||
using namespace Steinberg;
|
||||
|
||||
// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperUIEmbedInterface::iid; some
|
||||
// TU must DEFINE it. This is the only place that answers queryInterface for it, so the
|
||||
// definition lives with its sole use (mirrors reaper_bridge.cpp doing this for
|
||||
// IReaperHostApplication).
|
||||
// This is the only TU that answers queryInterface for IReaperUIEmbedInterface, so the
|
||||
// DEFINE lives here (mirrors reaper_bridge.cpp's IReaperHostApplication).
|
||||
DEF_CLASS_IID(Steinberg::IReaperUIEmbedInterface)
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Real-namespace-home using-directives (Q-W6: the namespaces.h shim is retired):
|
||||
// the embed strip speaks the map vocabulary (listSamples / parseBankGeneration) and
|
||||
// the pure UI layout (embed_strip / editor_geometry Rect) wholesale.
|
||||
using namespace reasampler::instrument::map;
|
||||
using namespace reasampler::instrument::ui;
|
||||
using reasampler::ui::spectralColor;
|
||||
@@ -52,15 +47,13 @@ using version::vstPluginName;
|
||||
|
||||
namespace {
|
||||
#ifdef _WIN32
|
||||
// Kit adapter (Phase L, L3): the embed shell's Rect (editor_geometry) -> the kit's KitBox
|
||||
// (component_geometry). Every embed surface now draws by palette ROLE via the L1 kit, retiring
|
||||
// the local pre-L1 forest-green palette + raw GDI DrawTextA.
|
||||
// Kit adapter: the embed shell's Rect -> the kit's KitBox.
|
||||
KitBox toKitBox(const Rect& r) {
|
||||
return KitBox{r.x, r.y, r.width, r.height};
|
||||
}
|
||||
|
||||
// A short display name for a bank sample id, from the snapshotted list (the editor's helper,
|
||||
// duplicated small rather than shared across the shell/pure boundary).
|
||||
// A short display name for a bank sample id (small duplicate of the editor's helper
|
||||
// rather than shared across the shell/pure boundary).
|
||||
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) {
|
||||
for (const SampleChoice& c : samples) {
|
||||
if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
|
||||
@@ -69,9 +62,8 @@ std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::str
|
||||
}
|
||||
#endif
|
||||
|
||||
// Project the instrument's performance map into the strip's minimal zone shape (key ranges
|
||||
// only). Pure projection — kept here (shell side) because it reads PerformanceMap, a shell
|
||||
// type; embed_strip stays free of it.
|
||||
// Projects the performance map into the strip's minimal zone shape (key ranges only).
|
||||
// Kept shell-side because it reads PerformanceMap; embed_strip stays free of it.
|
||||
std::vector<EmbedZone> toEmbedZones(const PerformanceMap& map) {
|
||||
std::vector<EmbedZone> out;
|
||||
out.reserve(map.zones.size());
|
||||
@@ -104,28 +96,24 @@ void ReaSamplerEmbed::refresh() {
|
||||
void ReaSamplerEmbed::maybeRefresh() {
|
||||
if (!processor_) { refresh(); return; } // clears state; cheap
|
||||
|
||||
// The performance map is a cheap in-process accessor (mutex + copy), and the editor may
|
||||
// have edited zones with NO bank-content change — always re-snapshot it so a zone edit
|
||||
// reflects immediately.
|
||||
// The performance map is a cheap in-process accessor, and the editor may edit zones
|
||||
// with no bank-content change — always re-snapshot it so an edit reflects immediately.
|
||||
map_ = processor_->performanceMap();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
|
||||
// The EXPENSIVE part is the bank-blob bridge read (samples_). Gate it on the S9 bank-
|
||||
// generation stamp (a small ext-state read): only re-read the bank when the generation
|
||||
// changed since the last paint (a recapture / ingest / remove), or on the first paint
|
||||
// (lastSeenBankGeneration_ == -1). A pre-S9 project reads generation 0; the first paint
|
||||
// folds it and subsequent idle paints skip the bank read entirely.
|
||||
// The expensive part is the bank-blob bridge read: gate it on the bank-generation
|
||||
// stamp, re-reading only when it changed (or on the first paint). A project with no
|
||||
// stamp reads generation 0; the first paint folds it and idle paints skip the read.
|
||||
std::int64_t currentGen = lastSeenBankGeneration_;
|
||||
if (auto rawGen =
|
||||
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBankGenKey)) {
|
||||
currentGen = parseBankGeneration(*rawGen);
|
||||
} else if (lastSeenBankGeneration_ < 0) {
|
||||
currentGen = 0; // unprimed + no stamp (pre-S9): treat as generation 0 for the first read
|
||||
currentGen = 0; // unprimed + no stamp: treat as generation 0 for the first read
|
||||
}
|
||||
// Intentional asymmetry: a TRANSIENT bridge failure (readReasamplerExtState returned
|
||||
// nullopt after we were already primed) leaves currentGen == lastSeenBankGeneration_,
|
||||
// so the bank-blob read is skipped and the editor keeps its last-known sample list.
|
||||
// A stale-but-intact list is better than clearing samples_ on every transient hiccup.
|
||||
// Intentional asymmetry: a transient bridge failure after priming leaves currentGen
|
||||
// unchanged, skipping the read — a stale-but-intact list beats clearing samples_ on
|
||||
// every hiccup.
|
||||
|
||||
if (lastSeenBankGeneration_ < 0 || currentGen != lastSeenBankGeneration_) {
|
||||
auto banks =
|
||||
@@ -145,9 +133,8 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
|
||||
#endif
|
||||
case REAPER_FXEMBED_WM_CREATE:
|
||||
#ifdef _WIN32
|
||||
// Create the kit's cached AA fonts before the first paint (Phase L, L3).
|
||||
// Idempotent + process-global (shared with the editor in this binary); NOT torn
|
||||
// down per-view — the OS reclaims the tiny static HFONT set at module unload.
|
||||
// Idempotent + process-global (shared with the editor); not torn down per-view
|
||||
// — the OS reclaims the tiny static HFONT set at module unload.
|
||||
kitFontsInit();
|
||||
#endif
|
||||
refresh(); // prime the first paint's snapshot
|
||||
@@ -157,8 +144,7 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
|
||||
case REAPER_FXEMBED_WM_GETMINMAXINFO: {
|
||||
auto* hints = reinterpret_cast<REAPER_FXEMBED_SizeHints*>(parm3);
|
||||
if (!hints) return 0;
|
||||
// Minimum usable strip height: the keymap must not collapse below its floor
|
||||
// (kEmbedKeymapMinHeight) plus the level band.
|
||||
// The keymap must not collapse below its floor plus the level band.
|
||||
hints->min_width = 64;
|
||||
hints->max_width = 0; // 0 = unconstrained
|
||||
hints->min_height = kEmbedKeymapMinHeight + kEmbedLevelBandHeight;
|
||||
@@ -172,7 +158,7 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
|
||||
case REAPER_FXEMBED_WM_PAINT:
|
||||
return paint(parm2, parm3) ? 1 : 0;
|
||||
case REAPER_FXEMBED_WM_LBUTTONDOWN:
|
||||
// Selection at most (S6): map the click to a zone; force a redraw if it changed.
|
||||
// Selection at most: map the click to a zone; force a redraw if it changed.
|
||||
return onMouseDown(parm3) ? REAPER_FXEMBED_RETNOTIFY_INVALIDATE : 0;
|
||||
#endif
|
||||
default:
|
||||
@@ -190,35 +176,30 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
const int h = di->height;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
|
||||
// Re-read live state each paint (UI thread) so the strip reflects keymap edits + bank
|
||||
// changes without its own timer — REAPER repaints the embed surface on its cadence. S9
|
||||
// dirty-guard: maybeRefresh does the EXPENSIVE bank-blob read only when the bank generation
|
||||
// changed (the flagged S6 follow-up), always refreshing the cheap performance map.
|
||||
// Re-read live state each paint (no own timer) — REAPER repaints the embed surface on
|
||||
// its own cadence.
|
||||
maybeRefresh();
|
||||
|
||||
// REAPER hands us its own bitmap sized to the embed area; draw directly into it (unlike
|
||||
// the editor, which owns a LICE_SysBitmap and BitBlt's). Origin is the bitmap's (0,0).
|
||||
// Base canvas through the kit (bg/base + micro-gradient), Phase L L3.
|
||||
// REAPER hands us its own bitmap sized to the embed area; draw directly into it
|
||||
// (unlike the editor, which owns a LICE_SysBitmap and BitBlt's).
|
||||
fillSurface(bmp, KitBox{0, 0, w, h}, Role::BgBase, InteractionState::Rest);
|
||||
|
||||
const EmbedLayout layout = layoutEmbed(w, h);
|
||||
|
||||
if (map_.zones.empty()) {
|
||||
// No opt-in zones authored: a faint bg/cell band spanning the keymap area so the strip
|
||||
// reads as "present, no zones" — the default single-capture face lives in the editor.
|
||||
// No opt-in zones authored: a faint band so the strip reads as "present, no zones"
|
||||
// — the default single-capture face lives in the editor.
|
||||
LICE_FillRect(bmp, layout.keymap.x, layout.keymap.y, layout.keymap.width,
|
||||
layout.keymap.height, toLice(roleColor(Role::BgCell)), 0.5f, 0);
|
||||
const std::string label = version::vstPluginName() + // channel-derived (S18)
|
||||
const std::string label = version::vstPluginName() + // channel-derived
|
||||
(samples_.empty() ? " (bank empty)" : " (no zones)");
|
||||
const Rect labelR = Rect::ltrb(layout.keymap.x + 4, layout.keymap.y, layout.keymap.right(),
|
||||
layout.keymap.bottom());
|
||||
text(bmp, toKitBox(labelR), label.c_str(), Font::Label, Role::TextPrimary, Align::Left);
|
||||
} else {
|
||||
// Draw each zone as a segment across the keymap span, first-match order (so the painted
|
||||
// order matches selection + playback). Each segment takes its PASTEL SPECTRAL hue from
|
||||
// the center of its key span (spectralColor — §4), so the strip reads as the same
|
||||
// spectrum as the editor's keyboard strip. The SELECTED zone lifts to accent-primary
|
||||
// + a static glow ("which zone is live", never a pulse — §3.5).
|
||||
// Each zone draws as a segment (first-match order, matching selection/playback),
|
||||
// colored by its key span's spectral hue so it reads as the same spectrum as the
|
||||
// editor's keyboard strip. The selected zone lifts to accent-primary + a static glow.
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[i];
|
||||
const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote);
|
||||
@@ -237,9 +218,8 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
}
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
// Label the segment with the sample name when it is wide enough to read. The
|
||||
// selected (accent-fill) segment draws its label in bg/base for contrast (the
|
||||
// tight text-on-pastel pair, §4); the rest in text/primary.
|
||||
// Label when wide enough to read; the selected (accent-fill) segment labels in
|
||||
// bg/base for contrast, the rest in text/primary.
|
||||
if (r.width >= 24) {
|
||||
const Rect lr = Rect::ltrb(r.x + 3, r.y, r.right() - 2, r.bottom());
|
||||
text(bmp, toKitBox(lr), sampleLabel(samples_, z.sampleId).c_str(),
|
||||
@@ -248,8 +228,8 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// The level band: a recessed bg/cell channel with an accent-primary fill following the
|
||||
// live activity level (a direct level follow — the one permitted "motion", §3.5).
|
||||
// The level band: a recessed channel with an accent-primary fill tracking the live
|
||||
// activity level (the one permitted "motion").
|
||||
if (layout.levelBand.height > 0) {
|
||||
fillSurface(bmp, toKitBox(layout.levelBand), Role::BgCell, InteractionState::Pressed);
|
||||
const double level = processor_ ? processor_->embedActivityLevel() : 0.0;
|
||||
|
||||
@@ -1,34 +1,9 @@
|
||||
// reasampler_embed.h — the S6 embedded TCP/MCP UI shell. Implements REAPER's
|
||||
// IReaperUIEmbedInterface (vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h +
|
||||
// reaper_vst3_interfaces.h) so the instrument draws a compact keymap/level strip INLINE in
|
||||
// the track/mixer control panel — the same Cockos surface REAPER's own embedded FX use.
|
||||
//
|
||||
// VERIFIED CONTRACT (against reaper_plugin_fx_embed.h + reaper_vst3_interfaces.h):
|
||||
// * VST3 exposes this by having the IEditController answer queryInterface for
|
||||
// IReaperUIEmbedInterface (iid {0x049bf9e7,0xbc74ead0,0xc4101e86,0x7f725981}). Our
|
||||
// SingleComponentEffect IS the edit controller, so the processor's queryInterface hands
|
||||
// REAPER a reference to this object.
|
||||
// * The single method is embed_message(int msg, TPtrInt parm2, TPtrInt parm3). msg is a
|
||||
// REAPER_FXEMBED_WM_* value (aliased to Win32 WM_*):
|
||||
// - WM_IS_SUPPORTED (0x0000): return 1 (supported+available), -1, or 0.
|
||||
// - WM_CREATE (0x0001) / WM_DESTROY (0x0002): embed begin/end; return ignored.
|
||||
// - WM_PAINT (0x000F): parm2 = REAPER_FXEMBED_IBitmap* (alias LICE_IBitmap) to draw
|
||||
// into; parm3 = REAPER_FXEMBED_DrawInfo* (context TCP=1/MCP=2, width/height, mouse,
|
||||
// flags). Return 1 if drawing occurred, 0 otherwise.
|
||||
// - WM_GETMINMAXINFO (0x0024): parm3 = SizeHints*; return 1 if filled.
|
||||
// - mouse WM_* (0x0200..0x020A): parm3 = DrawInfo*; return RETNOTIFY_INVALIDATE
|
||||
// (0x1000000) to force a redraw. Capture is auto-managed by the host.
|
||||
// * There is NO plugin-owned window/HWND here (unlike the IPlugView editor): REAPER hands
|
||||
// a LICE bitmap per paint; we only draw into it and read mouse coords from DrawInfo.
|
||||
//
|
||||
// RT DISCIPLINE (S6 constraint): all embed messages arrive on REAPER's UI thread; nothing
|
||||
// here runs in process(). It reads the same live state the editor reads (bank over the
|
||||
// bridge + the processor's performance map) with the same off-audio-thread accessors — no
|
||||
// new locks visible to process, read-only over the bank. Windows-only (D5), guarded so a
|
||||
// non-Windows build stays compilable.
|
||||
//
|
||||
// The strip's LAYOUT + HIT-TEST is pure (embed_strip.h, unit-tested); this shell marshals
|
||||
// REAPER's messages to/from it and draws with the same LICE idiom as reasampler_editor.
|
||||
// reasampler_embed.h — the embedded TCP/MCP UI shell. Implements REAPER's
|
||||
// IReaperUIEmbedInterface so the instrument draws a compact keymap/level strip inline in
|
||||
// the track/mixer control panel. All embed messages arrive on REAPER's UI thread; nothing
|
||||
// here runs in process(). Windows-only, guarded so a non-Windows build stays compilable.
|
||||
// The strip's layout + hit-test is pure (embed_strip.h, unit-tested); this shell marshals
|
||||
// REAPER's messages to/from it.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -51,27 +26,30 @@ namespace reasampler::vst {
|
||||
|
||||
class ReaSamplerProcessor;
|
||||
|
||||
// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired).
|
||||
using instrument::map::PerformanceMap;
|
||||
using instrument::map::SampleChoice;
|
||||
|
||||
// Implements IReaperUIEmbedInterface. Lifetime is OWNED by the processor (the processor
|
||||
// holds the sole unique_ptr and hands out AddRef'd references from queryInterface); the
|
||||
// back-pointer to the processor is therefore always valid while this lives.
|
||||
// Implements IReaperUIEmbedInterface. Lifetime is owned by the processor (sole unique_ptr,
|
||||
// hands out AddRef'd references from queryInterface); the back-pointer to the processor is
|
||||
// therefore always valid while this lives.
|
||||
class ReaSamplerEmbed : public Steinberg::IReaperUIEmbedInterface {
|
||||
public:
|
||||
explicit ReaSamplerEmbed(ReaSamplerProcessor* processor) : processor_(processor) {}
|
||||
|
||||
// The one embed entry point. Routes each REAPER_FXEMBED_WM_* message; see the header
|
||||
// note above for the per-message contract. UI thread only.
|
||||
// The one embed entry point, verified against reaper_plugin_fx_embed.h +
|
||||
// reaper_vst3_interfaces.h: our IEditController answers queryInterface for
|
||||
// IReaperUIEmbedInterface. msg is a REAPER_FXEMBED_WM_* value (aliased to Win32 WM_*)
|
||||
// — WM_IS_SUPPORTED, WM_CREATE/WM_DESTROY, WM_PAINT (parm2 = IBitmap*, parm3 =
|
||||
// DrawInfo*), WM_GETMINMAXINFO (parm3 = SizeHints*), mouse WM_* (return
|
||||
// RETNOTIFY_INVALIDATE to force a redraw). No plugin-owned HWND here (unlike the
|
||||
// IPlugView editor): REAPER hands a LICE bitmap per paint. UI thread only.
|
||||
Steinberg::TPtrInt embed_message(int msg, Steinberg::TPtrInt parm2,
|
||||
Steinberg::TPtrInt parm3) override;
|
||||
|
||||
// FUnknown: this object's lifetime is owned by the processor, not the host refcount, so
|
||||
// AddRef/release are no-ops (the processor's unique_ptr governs destruction) and
|
||||
// queryInterface answers only FUnknown + IReaperUIEmbedInterface. This mirrors how the
|
||||
// SDK's OBJ refcount would otherwise churn; here the owning processor guarantees the
|
||||
// object outlives every borrowed reference REAPER holds during embedding.
|
||||
// FUnknown: lifetime is owned by the processor, not the host refcount, so
|
||||
// AddRef/release are no-ops and queryInterface answers only FUnknown +
|
||||
// IReaperUIEmbedInterface — the owning processor guarantees this outlives every
|
||||
// borrowed reference REAPER holds during embedding.
|
||||
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
|
||||
void** obj) override;
|
||||
Steinberg::uint32 PLUGIN_API addRef() override { return 1000; }
|
||||
@@ -79,37 +57,29 @@ public:
|
||||
|
||||
private:
|
||||
#ifdef _WIN32
|
||||
// Draw the current strip into REAPER's supplied LICE bitmap. Returns true if it drew.
|
||||
// Draws the current strip into REAPER's supplied LICE bitmap. Returns true if it drew.
|
||||
bool paint(Steinberg::TPtrInt bitmap, Steinberg::TPtrInt drawInfo);
|
||||
// Handle a mouse-down inside the strip: map to a zone and select it (S6: selection at
|
||||
// most — no new editing semantics). Returns true if the selection changed (the caller
|
||||
// then asks REAPER to invalidate).
|
||||
// A mouse-down inside the strip: maps to a zone and selects it (no new editing
|
||||
// semantics). Returns true if the selection changed (caller then invalidates).
|
||||
bool onMouseDown(Steinberg::TPtrInt drawInfo);
|
||||
#endif
|
||||
|
||||
// Snapshot the live bank + the instrument's performance map for the next paint, exactly
|
||||
// as the editor's refreshSampleList does (bridge read + processor accessors, UI thread).
|
||||
// Snapshots the live bank + the instrument's performance map for the next paint.
|
||||
void refresh();
|
||||
|
||||
// The S9 dirty-guard over refresh() (the S6 flagged follow-up): read the cheap bank-
|
||||
// generation stamp; do the EXPENSIVE bank-blob bridge read (refresh()) only when the
|
||||
// generation changed since the last paint (or on the first paint) — the strip re-read
|
||||
// per paint was wasteful now that a generation counter exists. The performance map (a
|
||||
// cheap in-process accessor, edited by the editor independently of bank content) is
|
||||
// ALWAYS refreshed so a zone edit still reflects immediately. UI thread only.
|
||||
// Dirty-guard over refresh(): re-reads the bank blob only when the (cheap) generation
|
||||
// stamp changed since the last paint. The performance map is always refreshed (cheap
|
||||
// in-process accessor) so a zone edit reflects immediately. UI thread only.
|
||||
void maybeRefresh();
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
// The bank generation last folded into samples_ (S9 dirty-guard). -1 forces the first
|
||||
// maybeRefresh() to do a full read (no generation can be negative — parseBankGeneration
|
||||
// yields >= 0 — so -1 is an "unprimed" sentinel distinct from a real generation 0).
|
||||
// The bank generation last folded into samples_. -1 is an "unprimed" sentinel distinct
|
||||
// from a real generation 0, forcing the first maybeRefresh() to do a full read.
|
||||
std::int64_t lastSeenBankGeneration_ = -1;
|
||||
// Snapshotted for the current paint (refreshed each paint off the audio thread).
|
||||
std::vector<SampleChoice> samples_;
|
||||
PerformanceMap map_;
|
||||
// The zone the last click selected (local/visual only — S6 selection constraint; the
|
||||
// processor's editor-shared selection is NOT updated from here); -1 = none.
|
||||
// Drives the strip's highlight.
|
||||
// The zone the last click selected (local/visual only); -1 = none. Drives the strip's
|
||||
// highlight.
|
||||
int selectedZone_ = -1;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// reasampler_processor.cpp — see reasampler_processor.h. Since Q-W2v (T4-12) this TU is
|
||||
// the VST3 LIFECYCLE + the REAL-TIME process() path ONLY: factory/queryInterface,
|
||||
// initialize/terminate/setActive, bus setup, and the block render (MIDI marshal, preview
|
||||
// mailbox drain, engine + drain sum, master-gain ramp). Component-state I/O + parameter
|
||||
// accessors live in processor_state.cpp; the off-thread reload/publish family lives in
|
||||
// processor_reload.cpp. process() and its per-block work stay ONE TU (T4-29): no virtual
|
||||
// seam, no cross-TU call on the per-sample path.
|
||||
// reasampler_processor.cpp — see reasampler_processor.h. This TU is the VST3 lifecycle +
|
||||
// the real-time process() path only: factory/queryInterface, initialize/terminate/
|
||||
// setActive, bus setup, and the block render. Component-state I/O + parameter accessors
|
||||
// live in processor_state.cpp; the off-thread reload/publish family lives in
|
||||
// processor_reload.cpp. process() and its per-block work stay one TU on purpose — no
|
||||
// virtual seam, no cross-TU call on the per-sample path.
|
||||
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -17,8 +16,8 @@
|
||||
#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic)
|
||||
#include "pluginterfaces/vst/vstspeaker.h"
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor
|
||||
#include "shell/instrument/reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
|
||||
#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor
|
||||
#include "shell/instrument/reasampler_embed.h" // embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
|
||||
|
||||
using namespace Steinberg;
|
||||
using namespace Steinberg::Vst;
|
||||
@@ -27,20 +26,15 @@ namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a
|
||||
// linear per-sample step derived from this at setupProcessing (gainRampStep_ =
|
||||
// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing
|
||||
// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as
|
||||
// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is
|
||||
// ~20 ms at EVERY host rate; the snap threshold (half a step, below which gainCurrent_
|
||||
// jumps to the target) avoids long sub-LSB creep and the ramp loop on idle blocks.
|
||||
// Post-mixer gain ramp time (wall-clock): gainRampStep_ = 1/(kGainRampSeconds *
|
||||
// sampleRate_), per the no-hardcoded-rate ruling — ~20 ms full ramp at every host rate.
|
||||
constexpr double kGainRampSeconds = 0.020;
|
||||
|
||||
} // namespace
|
||||
|
||||
FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
|
||||
// The host owns the returned reference. Cast up to the combined interface the SDK
|
||||
// exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
|
||||
// The host owns the returned reference; cast to IAudioProcessor so the FUnknown
|
||||
// refcount is correctly rooted.
|
||||
return static_cast<IAudioProcessor*>(new ReaSamplerProcessor());
|
||||
}
|
||||
|
||||
@@ -48,10 +42,8 @@ FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
|
||||
ReaSamplerProcessor::~ReaSamplerProcessor() = default;
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) {
|
||||
// S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for
|
||||
// IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed
|
||||
// shell. We own the shell (unique_ptr); the borrowed reference is valid because the
|
||||
// processor outlives it. All other iids fall through to the SDK's queryInterface.
|
||||
// REAPER queries the IEditController for IReaperUIEmbedInterface; hand it our
|
||||
// lazily-created embed shell (the processor outlives the borrowed reference).
|
||||
if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) {
|
||||
if (!embed_) embed_ = std::make_unique<ReaSamplerEmbed>(this);
|
||||
embed_->addRef();
|
||||
@@ -69,17 +61,11 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
||||
// instrument still loads, it just has no live bank to play.
|
||||
bridge_.connect(context);
|
||||
|
||||
// Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no
|
||||
// audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of
|
||||
// the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders
|
||||
// dual-mono through the stereo bus (both channels equal, centered), which is audibly
|
||||
// identical to a mono bus but never asks the host to re-map a live instance's pins. The
|
||||
// prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every
|
||||
// mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The
|
||||
// in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer
|
||||
// write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's
|
||||
// re-routing of the live instance's pins across the arrangement change. A fixed arrangement
|
||||
// is the maximally-standard VSTi shape and removes that whole negotiation surface.
|
||||
// One event input (MIDI, 16 channels), one audio output, no audio input. The output
|
||||
// bus is fixed stereo regardless of channel mode (mono renders dual-mono, centered).
|
||||
// Do not reintroduce per-mode bus renegotiation: flipping kMono<->kStereo via
|
||||
// restartComponent previously panned a dual-mono capture hard right in the host's pin
|
||||
// re-routing (see testDualMonoStereoSampleRendersCentered).
|
||||
addEventInput(STR16("MIDI In"), 16);
|
||||
addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo);
|
||||
|
||||
@@ -87,9 +73,8 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
|
||||
// process() is not running at terminate: free the live + draining instruments and
|
||||
// drain the graveyard. Take the pointers out of the atomics first so nothing else
|
||||
// races them.
|
||||
// process() is guaranteed stopped at terminate: free the live + draining instruments
|
||||
// and drain the graveyard.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
delete live_.exchange(nullptr);
|
||||
delete draining_.exchange(nullptr);
|
||||
@@ -98,33 +83,24 @@ tresult PLUGIN_API ReaSamplerProcessor::terminate() {
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
||||
// Activating: build the instrument from the currently-selected sample so the first
|
||||
// block after activation can play. Deactivating: process is now GUARANTEED stopped by
|
||||
// the host, so this is the safe point to reclaim the graveyard (the displaced engines
|
||||
// no reload could free while active). The build/drain are off the audio thread —
|
||||
// setActive is a main/UI-thread call.
|
||||
// Activating: build from the currently-selected sample so the first block after
|
||||
// activation can play. Deactivating: process is now guaranteed stopped, so this is
|
||||
// the safe point to reclaim the graveyard. Main/UI-thread call.
|
||||
if (state) {
|
||||
// Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED
|
||||
// sample refs — it needs no bank read, so it plays regardless of whether the
|
||||
// extension's PROJEXTSTATE has parsed yet (or the extension exists at all).
|
||||
//
|
||||
// #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a
|
||||
// pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic
|
||||
// refreshRefsFromBank copies the refs in when the bank blob is readable by
|
||||
// activation time, so an upgraded project plays on load without the instrument
|
||||
// ever being opened (and the next save is self-contained). Residual load-order
|
||||
// race, DAW-verifiable only: if the host activates this instance BEFORE the
|
||||
// project's ext-state lines parse, the lift misses here and — with no editor open —
|
||||
// nothing retries until the next activation or editor tick. MIGRATION NOTE: open a
|
||||
// pre-v10 instrument once after upgrading if it restores silent.
|
||||
// Resolves + decodes from the instance-owned refs — no bank read needed, so it
|
||||
// plays regardless of PROJEXTSTATE parse state. Also doubles as the non-editor
|
||||
// legacy-lift trigger for a pre-v10 blob: reloadInstrument's opportunistic
|
||||
// refreshRefsFromBank copies refs in when the bank blob is readable by now.
|
||||
// Residual load-order race (DAW-verifiable only): if the host activates before the
|
||||
// project's ext-state parses, nothing retries until the next activation or editor
|
||||
// tick — open a pre-v10 instrument once after upgrading if it restores silent.
|
||||
reloadInstrument();
|
||||
} else {
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
// process is guaranteed stopped: free EVERYTHING. The live instrument too — its
|
||||
// voices are frozen mid-flight, and if it survived deactivation the reactivate
|
||||
// reload would displace it into the DRAIN slot, resurrecting stale sustained
|
||||
// voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above),
|
||||
// so nothing is lost by clearing here.
|
||||
// Free EVERYTHING, including live_: its voices are frozen mid-flight, and if it
|
||||
// survived deactivation the reactivate reload would displace it into the drain
|
||||
// slot, resurrecting stale sustained voices as ghosts. Reactivation rebuilds from
|
||||
// scratch above, so nothing is lost.
|
||||
delete live_.exchange(nullptr);
|
||||
delete draining_.exchange(nullptr);
|
||||
graveyard_.clear();
|
||||
@@ -135,9 +111,8 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
|
||||
sampleRate_ = setup.sampleRate;
|
||||
maxBlockSize_ = setup.maxSamplesPerBlock;
|
||||
// T3-01: resolve the FB1 gain-ramp step against the live host rate (20 ms wall-clock at
|
||||
// every rate). At 48 kHz this is exactly the former 1/960 constant. Written here (host
|
||||
// guarantees setupProcessing never overlaps process), read on the audio thread only.
|
||||
// Resolve the gain-ramp step against the live host rate (host guarantees
|
||||
// setupProcessing never overlaps process).
|
||||
if (sampleRate_ > 0.0) {
|
||||
gainRampStep_ = static_cast<float>(1.0 / (kGainRampSeconds * sampleRate_));
|
||||
}
|
||||
@@ -147,11 +122,10 @@ tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
|
||||
SpeakerArrangement* inputs, int32 numIns,
|
||||
SpeakerArrangement* outputs, int32 numOuts) {
|
||||
// ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a
|
||||
// decode policy, never a bus fact). We take NO audio input, so any inputs are rejected.
|
||||
// Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse)
|
||||
// and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a
|
||||
// proposal keeps a valid arrangement of its own) — the host adapts its routing to us.
|
||||
// Fixed stereo output bus (channel mode is a decode policy, never a bus fact); no audio
|
||||
// input, so any inputs are rejected. Accept only a single stereo output proposal;
|
||||
// otherwise reject and keep stereo (per the VST3 contract, a plug-in that can't honor a
|
||||
// proposal keeps a valid arrangement of its own) — the host adapts to us.
|
||||
if (numIns < 0 || numOuts < 0) return kInvalidArgument;
|
||||
if (numIns > 0) return kResultFalse; // no audio input bus to arrange
|
||||
if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue;
|
||||
@@ -159,23 +133,19 @@ tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
// REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments
|
||||
// once for the whole block (two atomic acquires), then publish the MINIMUM installedAt
|
||||
// over the pointers held so the off-thread graveyard pruner knows exactly which
|
||||
// generations this block is holding (see the header proof).
|
||||
// Real-time: no allocation, no IO, no locks. Load live + draining once for the whole
|
||||
// block (two atomic acquires), then publish the minimum installedAt over the pointers
|
||||
// held so the off-thread graveyard pruner knows which generations this block holds (see
|
||||
// the header's drain-slot proof). We publish installedAt rather than re-reading
|
||||
// reloadGeneration_ to close an ordering race: a fresh read could observe a generation
|
||||
// newer than the pointers actually held, letting the pruner free an instrument still
|
||||
// in use.
|
||||
//
|
||||
// We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an
|
||||
// ordering race: reading reloadGeneration_ after the slots could observe a generation
|
||||
// newer than the pointers we actually hold, causing the pruner to free an instrument
|
||||
// process is still reading. installedAt was set on the reload path before the atomic
|
||||
// exchange that made the instrument visible.
|
||||
//
|
||||
// The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the
|
||||
// last reload: its already-sounding voices keep rendering (and receive note-offs) so a
|
||||
// curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons.
|
||||
// A racing reload can briefly leave the same pointer in both slots (live_ was loaded
|
||||
// before the swap, draining_ after); collapse that to live-only so one engine is never
|
||||
// advanced twice per frame.
|
||||
// The drain instrument is the previously-live snapshot displaced by the last reload:
|
||||
// its already-sounding voices keep rendering (and receive note-offs) so an edit never
|
||||
// cuts a ringing note; it receives no note-ons. A racing reload can briefly leave the
|
||||
// same pointer in both slots (live_ loaded before the swap, draining_ after); collapse
|
||||
// that to live-only so one engine is never advanced twice per frame.
|
||||
LoadedInstrument* inst = live_.load(std::memory_order_acquire);
|
||||
LoadedInstrument* drain = draining_.load(std::memory_order_acquire);
|
||||
if (drain == inst) drain = nullptr;
|
||||
@@ -190,20 +160,16 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
}
|
||||
processGeneration_.store(heldGen, std::memory_order_release);
|
||||
|
||||
// Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine
|
||||
// voice silent) by naming its OWN installedAt (0 = no drain / still
|
||||
// sounding). Evaluated at block START — idleness is monotone for a drain (it receives no
|
||||
// note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply
|
||||
// publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe.
|
||||
// Publish whether the drain snapshot is fully idle, naming its own installedAt (0 = no
|
||||
// drain / still sounding). Idleness is monotone for a drain (no note-ons), so a
|
||||
// snapshot observed idle here stays idle. Bounded scan, relaxed store — RT-safe.
|
||||
drainIdleGeneration_.store(
|
||||
(drain && drain->fullyIdle()) ? drain->installedAt : 0,
|
||||
std::memory_order_relaxed);
|
||||
|
||||
// Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps
|
||||
// events at block granularity (no per-event sample-offset split) — audible timing is
|
||||
// within one block, adequate for Tier 0; sample-accurate scheduling is a later tier.
|
||||
// Note-offs also route to the DRAIN engine so a note held across a reload releases
|
||||
// its old-snapshot voice too (otherwise it would sustain until the next reload).
|
||||
// Marshal MIDI note-on/off at block granularity (no per-event sample-offset split;
|
||||
// sample-accurate scheduling is a later tier). Note-offs also route to the drain
|
||||
// engine so a note held across a reload releases its old-snapshot voice too.
|
||||
if (data.inputEvents) {
|
||||
const int32 count = data.inputEvents->getEventCount();
|
||||
for (int32 i = 0; i < count; ++i) {
|
||||
@@ -222,18 +188,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
if (inst) inst->engine.noteOff(e.noteOff.pitch);
|
||||
if (drain) drain->engine.noteOff(e.noteOff.pitch);
|
||||
} else if (e.type == Event::kLegacyMIDICCOutEvent) {
|
||||
// PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a
|
||||
// VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism
|
||||
// — the type is nominally an output event; DAW-verify, see handoff).
|
||||
// CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR
|
||||
// release tail; Trigger one-shots play through their bounded play length.
|
||||
// CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless
|
||||
// of play mode, including Trigger one-shots that ignore CC 123. This is the
|
||||
// true "panic" for a ringing one-shot (e.g. a full-length capture).
|
||||
// Both clear the mono held stack. Both apply to live AND drain. A ringing
|
||||
// preview note is a real engine voice since the PreviewCard retirement, so
|
||||
// the panics cover it with no separate routing. allNotesOff / allSoundsOff
|
||||
// are RT-safe (no allocation, bounded scans).
|
||||
// Panic: REAPER delivers raw input MIDI CC as kLegacyMIDICCOut events on the
|
||||
// INPUT event list (a REAPER-ism, DAW-verified). CC 123 (All Notes Off):
|
||||
// release semantics (Gate -> release tail; Trigger plays through). CC 120
|
||||
// (All Sounds Off): immediate hard silence, including Trigger. Both apply to
|
||||
// live + drain and cover a ringing preview note.
|
||||
const auto cc = static_cast<int>(e.midiCCOut.controlNumber);
|
||||
if (cc == kCtrlAllSoundsOff) {
|
||||
if (inst) inst->engine.allSoundsOff();
|
||||
@@ -246,16 +205,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
}
|
||||
}
|
||||
|
||||
// S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed
|
||||
// atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last
|
||||
// one we consumed; fire it once, then latch the sequence so the same request never re-fires.
|
||||
// Preview redesign: the drained requests drive the MAIN VoiceEngine — the exact
|
||||
// noteOn/noteOff calls the host MIDI marshal above makes — so a preview note is a real
|
||||
// voice: it counts against the voice count, can steal / be stolen, and respects
|
||||
// Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's
|
||||
// isolation). The editor posts the root note, so it plays at unity.
|
||||
// Consume (advance the sequence) even when inst is null so a note-on posted while no instrument
|
||||
// is loaded does not re-fire stale on the next instrument load.
|
||||
// Preview mailbox: drain off-thread preview-trigger requests (one relaxed atomic load
|
||||
// each). A request is new when its packed sequence differs from the last consumed; fire
|
||||
// once, then latch the sequence. Drives the main VoiceEngine — same noteOn/noteOff as
|
||||
// host MIDI, so a preview note is a real voice. Consume even when inst is null so a
|
||||
// note-on posted while nothing is loaded does not re-fire stale later.
|
||||
{
|
||||
const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire);
|
||||
const std::uint16_t onSeq = static_cast<std::uint16_t>(on >> 16);
|
||||
@@ -272,16 +226,12 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire);
|
||||
const std::uint16_t offSeq = static_cast<std::uint16_t>(off >> 16);
|
||||
if (offSeq != 0 && offSeq != previewOffConsumed_) {
|
||||
// Consume UNCONDITIONALLY (mirror of the on path): a stale off left pending
|
||||
// while nothing was loaded would otherwise survive until a (heal) reload lands
|
||||
// and release the NEXT preview press in the same block.
|
||||
previewOffConsumed_ = offSeq;
|
||||
// Route the preview note-off to BOTH engines (mirror of the host note-off): a
|
||||
// preview held across a reload — e.g. a curve edit committed mid-press — must
|
||||
// release the old-snapshot voice now draining, not just the (fresh) live one.
|
||||
// NOTE: preview shares the host-MIDI note space — noteOff releases the newest
|
||||
// voice at that pitch, so a preview release can release a host-held note at
|
||||
// Consume unconditionally (mirror of the on path) so a stale off does not
|
||||
// survive to release the NEXT preview press. Routes to both engines: a preview
|
||||
// held across a reload must release the old-snapshot voice too. NOTE: preview
|
||||
// shares the host-MIDI note space, so a release can release a host-held note at
|
||||
// the same pitch (inherent to routing preview through the real note path).
|
||||
previewOffConsumed_ = offSeq;
|
||||
if (inst) inst->engine.noteOff(static_cast<int>(off & 0xFF));
|
||||
if (drain) drain->engine.noteOff(static_cast<int>(off & 0xFF));
|
||||
}
|
||||
@@ -309,27 +259,22 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
// Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked
|
||||
// into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here
|
||||
// we simply match the buffers the host handed us: >=2 channels -> true stereo render into
|
||||
// ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the
|
||||
// render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here.
|
||||
// Render per the host's negotiated channel count (mode was baked into the decode
|
||||
// off-thread, so the mode itself is never read here): >=2 channels -> stereo into
|
||||
// ch0/ch1 (then mirror extras); exactly 1 -> mono. Adds into a cleared buffer.
|
||||
float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr;
|
||||
float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr;
|
||||
if (ch0 && ch1) {
|
||||
// Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo
|
||||
// path (both channels equal), so a mono capture in stereo mode is centered, not silent.
|
||||
// The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer).
|
||||
// A mono sample plays dual-mono via the engine's stereo path, so a mono capture in
|
||||
// stereo mode is centered, not silent. The drain engine's ringing tails add on top.
|
||||
for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; }
|
||||
if (inst) inst->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
|
||||
if (drain) drain->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
|
||||
// FB1 post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so
|
||||
// continuous knob drags produce no zipper noise and the true-zero bottom causes no click.
|
||||
// Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the
|
||||
// actual output. Branch-free inner loop; early-out when already at target. RT-safe.
|
||||
// Post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so
|
||||
// knob drags produce no zipper noise. Early-out when already at target.
|
||||
{
|
||||
const float gTarget = masterGain_.load(std::memory_order_relaxed);
|
||||
const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step
|
||||
const float gStep = gainRampStep_; // rate-derived per-sample step
|
||||
const float gSnap = 0.5f * gStep;
|
||||
const float diff = gTarget - gainCurrent_;
|
||||
if (diff < -gSnap || diff > gSnap) {
|
||||
@@ -349,13 +294,13 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
|
||||
// Channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
|
||||
for (int32 ch = 2; ch < out.numChannels; ++ch) {
|
||||
if (float* buf = out.channelBuffers32[ch]) {
|
||||
for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i];
|
||||
}
|
||||
}
|
||||
// Block peak (max across L/R) for the embed strip's level indicator; RT-safe.
|
||||
// Block peak (max across L/R) for the embed strip's level indicator.
|
||||
float peak = 0.f;
|
||||
for (int32 i = 0; i < frames; ++i) {
|
||||
const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i];
|
||||
@@ -365,16 +310,14 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
}
|
||||
embedPeak_.store(peak, std::memory_order_relaxed);
|
||||
} else if (ch0) {
|
||||
// Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel;
|
||||
// the replicate is defensive for a host that still hands >1 channel on a mono bus).
|
||||
// Mono: render into channel 0, replicate to any extra channels (defensive).
|
||||
for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f;
|
||||
if (inst) inst->engine.render(ch0, static_cast<std::size_t>(frames));
|
||||
if (drain) drain->engine.render(ch0, static_cast<std::size_t>(frames));
|
||||
// FB1 post-mixer master gain (mono path) — same ramp contract as the stereo branch:
|
||||
// post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe.
|
||||
// Same gain-ramp contract as the stereo branch above.
|
||||
{
|
||||
const float gTarget = masterGain_.load(std::memory_order_relaxed);
|
||||
const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step
|
||||
const float gStep = gainRampStep_; // rate-derived per-sample step
|
||||
const float gSnap = 0.5f * gStep;
|
||||
const float diff = gTarget - gainCurrent_;
|
||||
if (diff < -gSnap || diff > gSnap) {
|
||||
@@ -405,9 +348,8 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
}
|
||||
}
|
||||
|
||||
// Report silence only when nothing is loaded (lets the host optimize when idle).
|
||||
// With an instrument loaded — or a drain snapshot still ringing out — we clear the
|
||||
// flag so a ringing voice is not skipped.
|
||||
// Report silence only when nothing is loaded (lets the host optimize when idle); with
|
||||
// a drain snapshot still ringing out, clear the flag so it is not skipped.
|
||||
out.silenceFlags = (inst || drain) ? 0
|
||||
: ((out.numChannels >= 64)
|
||||
? ~0ULL
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
// reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the
|
||||
// pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio
|
||||
// output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the
|
||||
// engine's audio into the output bus — so a chosen bank sample plays chromatically from
|
||||
// its root note in REAPER's routing/record/render path.
|
||||
//
|
||||
// SingleComponentEffect is the SDK's combined processor+controller base — sanctioned
|
||||
// for a non-distributable, REAPER-only plugin under D5/D6. It gives us
|
||||
// addAudioOutput/addEventInput, IComponent setState/getState for the instance's own
|
||||
// state (the selected sample), and the IEditController seat so createView() can hand the
|
||||
// host our IPlugView LICE editor.
|
||||
//
|
||||
// SELF-CONTAINED PLAYBACK (pS architecture correction). The instance OWNS its sample: the
|
||||
// component state persists, per referenced bank sample, the project-relative WAV path +
|
||||
// decode intrinsics (SampleRefs), and reloadInstrument decodes straight from that table.
|
||||
// The extension's bank blob is a BROWSER SOURCE that opportunistically refreshes the refs
|
||||
// when readable — NEVER a runtime requirement for playback. A project restored before the
|
||||
// extension's PROJEXTSTATE parses (or with the extension absent) plays on load; the old
|
||||
// reopen-heal timer + poll-to-play machinery that papered over the bank dependency is gone.
|
||||
//
|
||||
// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO
|
||||
// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — ref resolve, WAV
|
||||
// decode, keymap build, VoiceEngine construction — all happens OFF the audio thread
|
||||
// (reloadInstrument, driven from the main/UI thread) and is handed to process via a
|
||||
// single atomic pointer swap. See the LoadedInstrument handoff below.
|
||||
// reasampler_processor.h — VST3 SingleComponentEffect wiring the pure sampler core into
|
||||
// a playable instrument: event-input + stereo output bus, MIDI -> VoiceEngine, render.
|
||||
// Self-contained playback: component state owns per-sample WAV path + decode intrinsics
|
||||
// (SampleRefs); the bank blob is an opportunistic browser source, never a playback
|
||||
// dependency. Audio thread (process()) does no allocation/file-IO/bridge calls/locks;
|
||||
// loading happens off-thread (reloadInstrument) and hands off via one atomic pointer swap.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -42,37 +23,24 @@
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim
|
||||
// is retired from the processor family; the engine family's symbols — Keymap, VoiceEngine,
|
||||
// ChannelMode, VoiceMode, MonoTrigger, the voice-count constants — still live in flat
|
||||
// `reasampler` and resolve via the enclosing namespace).
|
||||
using instrument::map::ComponentState;
|
||||
using instrument::map::PerformanceMap;
|
||||
using instrument::map::SampleRefs;
|
||||
using instrument::map::kPreviewVelocityDefault;
|
||||
|
||||
class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface)
|
||||
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
|
||||
|
||||
// One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice
|
||||
// engine that plays it. The engine holds references into the keymap, so the two MUST live
|
||||
// and die together at a STABLE address — hence this is heap-allocated and neither copyable
|
||||
// nor movable. The audio thread only ever reads it through an atomic pointer; it is built
|
||||
// and destroyed off the audio thread.
|
||||
//
|
||||
// installedAt: the reloadGeneration_ value at which this instrument was atomically
|
||||
// installed into live_. Set on the reload path before the exchange. process() publishes
|
||||
// this field (not a fresh re-read of reloadGeneration_) so the published generation is
|
||||
// exactly the generation of the instrument actually in hand for the block.
|
||||
// Decoded keymap + the voice engine playing it. The engine holds references into the
|
||||
// keymap, so both must live/die together at a stable address — heap-allocated,
|
||||
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
|
||||
struct LoadedInstrument {
|
||||
Keymap keymap;
|
||||
VoiceEngine engine;
|
||||
std::uint64_t installedAt = 0; // reload generation at which this was installed
|
||||
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_
|
||||
|
||||
// The takeover declick (GA fix, rev 2) is opted IN here — the PRODUCT default: any
|
||||
// restart of a sounding voice (mono Retrigger takeover/fallback, cross-sample legato
|
||||
// restart, POLY at-cap steal — the preview note included, now that it is a real pool
|
||||
// voice) smooths the cut via the difference-seeded ramp instead of clicking. The pure
|
||||
// core defaults it off (regression baseline) — same layering as kDefaultPitchEngine.
|
||||
// Takeover declick is on by default here (product default; the pure core defaults it
|
||||
// off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
|
||||
// of clicking.
|
||||
LoadedInstrument(Keymap km, std::size_t maxVoices,
|
||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||
std::int64_t preserveWindowFrames = 0,
|
||||
@@ -83,9 +51,8 @@ struct LoadedInstrument {
|
||||
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
|
||||
installedAt(gen) {}
|
||||
|
||||
// True when nothing in this snapshot is sounding. process() publishes this for the
|
||||
// drain slot so the off-thread retirer can park an idle drain in the graveyard early
|
||||
// (FA1-review Major #2). Bounded scan (<= maxVoices).
|
||||
// True when nothing in this snapshot is sounding; lets the off-thread retirer park an
|
||||
// idle drain early. Bounded scan (<= maxVoices).
|
||||
bool fullyIdle() const { return engine.activeVoiceCount() == 0; }
|
||||
|
||||
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||
@@ -95,8 +62,8 @@ struct LoadedInstrument {
|
||||
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
|
||||
public:
|
||||
ReaSamplerProcessor() = default;
|
||||
// Out-of-line so the owned ReaSamplerEmbed (held by unique_ptr, forward-declared here)
|
||||
// is a complete type at the destruction point (defined in the .cpp).
|
||||
// Out-of-line so the owned ReaSamplerEmbed (unique_ptr, forward-declared here) is
|
||||
// complete at the destruction point (defined in the .cpp).
|
||||
~ReaSamplerProcessor() override;
|
||||
|
||||
// The factory create function (registered in vst_entry.cpp).
|
||||
@@ -109,9 +76,9 @@ public:
|
||||
Steinberg::tresult PLUGIN_API terminate() override;
|
||||
Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override;
|
||||
|
||||
// Instance state = the selected bank sample id (D-B: a performance choice the
|
||||
// instrument owns; NEVER written back to the bank). Component-state, so a saved
|
||||
// REAPER project restores which sample each instance plays.
|
||||
// Instance state = the selected bank sample id (a performance choice the instrument
|
||||
// owns; never written back to the bank). Component-state, so a saved project restores
|
||||
// which sample each instance plays.
|
||||
Steinberg::tresult PLUGIN_API setState(Steinberg::IBStream* state) override;
|
||||
Steinberg::tresult PLUGIN_API getState(Steinberg::IBStream* state) override;
|
||||
|
||||
@@ -122,11 +89,9 @@ public:
|
||||
Steinberg::tresult PLUGIN_API process(
|
||||
Steinberg::Vst::ProcessData& data) override;
|
||||
|
||||
// Output-bus negotiation. The instrument has ONE canonical output arrangement: a FIXED
|
||||
// stereo bus (GA fix — the channel mode is a decode policy, never a bus fact; mono mode
|
||||
// renders dual-mono through it). We accept the host's proposal only when it is a single
|
||||
// stereo output; otherwise we reject (kResultFalse) but keep our stereo arrangement, so
|
||||
// getBusArrangement / getBusInfo always report 2 channels and the host routes accordingly.
|
||||
// Fixed stereo output bus — channel mode is a decode policy, never a bus fact; mono
|
||||
// renders dual-mono through it. Do not reintroduce per-instance bus renegotiation.
|
||||
// Accepts only a single stereo output proposal; otherwise rejects and keeps stereo.
|
||||
Steinberg::tresult PLUGIN_API setBusArrangements(
|
||||
Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns,
|
||||
Steinberg::Vst::SpeakerArrangement* outputs, Steinberg::int32 numOuts) override;
|
||||
@@ -135,107 +100,73 @@ public:
|
||||
// Hands the host our LICE IPlugView editor.
|
||||
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
|
||||
|
||||
// Override queryInterface to additionally expose REAPER's IReaperUIEmbedInterface (S6):
|
||||
// REAPER queries the IEditController for it to drive the inline TCP/MCP embed surface.
|
||||
// All other iids delegate to SingleComponentEffect's implementation unchanged.
|
||||
// Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the
|
||||
// inline TCP/MCP embed); all other iids delegate to SingleComponentEffect unchanged.
|
||||
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
|
||||
void** obj) override;
|
||||
|
||||
// The embedded-strip activity level (0..1), read by the S6 embed shell on the UI thread.
|
||||
// Backed by embedPeak_, the per-block mono peak the audio thread stores relaxed — a
|
||||
// lock-free advisory readout, never touched with a lock the audio thread could contend.
|
||||
// The embedded-strip activity level (0..1) for the embed shell, UI thread. Backed by
|
||||
// embedPeak_, a lock-free relaxed atomic the audio thread writes each block.
|
||||
double embedActivityLevel() const {
|
||||
return static_cast<double>(embedPeak_.load(std::memory_order_relaxed));
|
||||
}
|
||||
|
||||
// Called by the editor (main/UI thread) when the user picks a sample, and internally
|
||||
// on load. SELF-CONTAINED (pS): resolves the selection/zones against the instance-OWNED
|
||||
// SampleRefs table, decodes each WAV OFF the audio thread, and publishes the built
|
||||
// instrument to process() via an atomic swap — NO bank read is required for playback.
|
||||
// When the live bank blob IS readable it is first folded into the refs table
|
||||
// (refreshRefsFromBank), which is both the browser's copy-the-ref-in mechanism and the
|
||||
// S9 live-recapture sync. A missing/unreadable WAV is the defined no-play (silence, no
|
||||
// retry). Returns the resolved selection id ("" if nothing was loaded) for the editor.
|
||||
// Resolves selection/zones against the instance-owned SampleRefs, decodes each WAV
|
||||
// off-thread, and publishes the built instrument via atomic swap — no bank read
|
||||
// required. When the bank blob is readable it's first folded into the refs table
|
||||
// (refreshRefsFromBank; the browser's copy-the-ref-in + recapture-sync mechanism). A
|
||||
// missing/unreadable WAV is the defined no-play (silence, no retry). Returns the
|
||||
// resolved selection id ("" if nothing loaded).
|
||||
std::string reloadInstrument();
|
||||
|
||||
// The result of a bank-sync poll (S9/S8): what pollBankSync did this tick, so the editor
|
||||
// can react (repaint / re-snapshot its own view) only when something actually changed.
|
||||
// What pollBankSync did this tick, so the editor can react only when something changed.
|
||||
struct BankSyncResult {
|
||||
// The bank generation changed (or a pre-v10 legacy lift landed an instrument) ->
|
||||
// reloadInstrument ran and the editor should re-snapshot its bank view.
|
||||
bool reloaded = false;
|
||||
bool reloaded = false; // bank generation changed (or a legacy lift landed) -> reloaded
|
||||
bool applied = false; // a new assignment request was applied -> selection changed
|
||||
};
|
||||
|
||||
// Poll the S9 bank-generation counter and the S8 assignment request over the bridge, OFF
|
||||
// THE AUDIO THREAD (the editor's UI timer drives this — NEVER process()). This is an
|
||||
// EDITOR/BROWSER sync path — playback never depends on it (pS). Semantics:
|
||||
// * S9: if the bank generation differs from what we last saw, call reloadInstrument() so
|
||||
// a recapture/ingest refreshes playback hands-free (atomic swap, glitch-free).
|
||||
// * S8: if a NEW (generation > last consumed) assignment request names a resolvable
|
||||
// sample AND this instance is the target (isFocusedTarget), apply it as the selection
|
||||
// and reload; an unresolvable request is DROPPED silently (marker advanced, no change);
|
||||
// a non-target instance neither applies nor advances its marker.
|
||||
// * LEGACY LIFT: a pre-v10 blob restored with intent but no refs retries the (cheap)
|
||||
// bank read until the blob is parseable, then reloads ONCE to copy the refs in.
|
||||
// TERMINATING: once the blob parses and NO referenced id resolves, the ids are
|
||||
// provably stale — the lift concludes permanently (legacyLiftShouldRun) instead of
|
||||
// churning a full bank read + reload every tick forever.
|
||||
// The consumed marker advances in component state (marked dirty via the host handler) so a
|
||||
// re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input
|
||||
// (the editor passes true only for the instance whose editor is open — see the handoff).
|
||||
// Idempotent on an idle tick (generation unchanged + no new request -> no work).
|
||||
// Off-thread poll (editor's UI timer only) of the bank generation + assignment request;
|
||||
// playback never depends on it. Generation change -> reload; a resolvable NEW assignment
|
||||
// targeting this instance (isFocusedTarget) -> apply as selection + reload (unresolvable
|
||||
// ones drop silently, marker still advances); pre-v10 legacy blobs retry the bank read
|
||||
// until the refs lift in, then stop (legacyLiftShouldRun). The consumed marker persists
|
||||
// so a re-open does not re-apply. Idempotent on an idle tick.
|
||||
BankSyncResult pollBankSync(bool isFocusedTarget);
|
||||
|
||||
// The bridge, for the editor's live-state readout + sample list. Owned here; the
|
||||
// editor borrows it (outlives the editor).
|
||||
ReaperBridge& bridge() { return bridge_; }
|
||||
|
||||
// The live host sample rate latched from setupProcessing (the SAME rate reloadInstrument
|
||||
// resolves seconds->frames against). The editor's S-VIEW-3 envelope overlay reads it to place
|
||||
// its wall-clock seconds on the same time base the voice engine plays them over. 0.0 before
|
||||
// setupProcessing runs (the editor guards). Read on the UI thread; a plain load — sampleRate_
|
||||
// is set once by setupProcessing before any audio and does not change under the editor.
|
||||
// The live host sample rate latched from setupProcessing; the editor's envelope overlay
|
||||
// shares this time base. 0.0 before setupProcessing runs.
|
||||
double sampleRate() const { return sampleRate_; }
|
||||
// The current single-capture selection id (main/UI thread reads for the editor). Guarded
|
||||
// by selectionMutex_ — never touched on the audio thread. Since S10 this is the ONE picked
|
||||
// capture the default face plays chromatically when the performance map is empty; an EMPTY
|
||||
// id resolves to SILENCE (no first-sample fallback). A non-empty zoned map supersedes it.
|
||||
// The single-capture selection id (guarded by selectionMutex_, never read on the audio
|
||||
// thread): the default face's pick when the performance map is empty; a non-empty map
|
||||
// supersedes it. Empty id -> silence, no first-sample fallback.
|
||||
std::string selectedSampleId();
|
||||
void setSelectedSampleId(const std::string& id);
|
||||
|
||||
// The performance map (Tier 1: the zoned keymap the instrument owns; D-B). Read/written
|
||||
// by the editor on the UI thread; snapshotted under performanceMutex_. NEVER read on the
|
||||
// audio thread — reloadInstrument bakes it into the LoadedInstrument's Keymap off-thread.
|
||||
// The performance map (zoned keymap). UI thread, guarded by performanceMutex_; never
|
||||
// read on the audio thread — reloadInstrument bakes it into the Keymap off-thread.
|
||||
PerformanceMap performanceMap();
|
||||
void setPerformanceMap(const PerformanceMap& map);
|
||||
|
||||
// The per-instance channel mode (S7, D-E: mono | stereo). Read/written on the UI thread
|
||||
// (the editor toggle) and read off-thread by getState/reloadInstrument; guarded by
|
||||
// channelModeMutex_. NEVER read on the audio thread — process() renders against the host's
|
||||
// negotiated output channel count, and reloadInstrument bakes the mode into the decode.
|
||||
// GA fix: the mode is a DECODE policy only (downmix vs L/R split). The output bus is a
|
||||
// FIXED stereo bus — mono mode renders dual-mono through it (centered) — so a mode change
|
||||
// never renegotiates host I/O (the mono<->stereo bus flip's live pin remap was the
|
||||
// hard-right-pan defect).
|
||||
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read
|
||||
// on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is
|
||||
// fixed stereo, so a mode change never renegotiates host I/O.
|
||||
ChannelMode channelMode();
|
||||
// Sets the mode from the EDITOR TOGGLE (a deliberate user choice): latches the mode
|
||||
// EXPLICIT (the GA auto-default stops fighting it), and on a CHANGE reloads the instrument
|
||||
// so the next block decodes the new channel count. UI thread only.
|
||||
// Editor toggle: latches the mode explicit (auto-default stops fighting it) and
|
||||
// reloads so the next block decodes the new channel count. UI thread only.
|
||||
void setChannelMode(ChannelMode mode);
|
||||
|
||||
// The per-instance preview-trigger velocity (S-VIEW-4, MIDI 1..127). Read/written on the
|
||||
// UI thread (the Sample-view velocity knob) and by getState/setState (host load-save thread);
|
||||
// guarded by previewMutex_. Persisted in component state (v6). NOT read on the audio thread.
|
||||
// Per-instance preview-trigger velocity (MIDI 1..127), guarded by previewMutex_, not
|
||||
// read on the audio thread.
|
||||
std::uint8_t previewVelocity();
|
||||
void setPreviewVelocity(std::uint8_t velocity);
|
||||
|
||||
// --- Phase S voice-system parameters (per-instance, persisted in component state v7) ---
|
||||
// Read/written on the UI thread (the editor's voice deck) and by getState/setState; guarded
|
||||
// by voiceParamsMutex_. NOT read on the audio thread — each setter rebuilds the VoiceEngine
|
||||
// OFF-thread via rebuildVoiceEngine (a LIGHT rebuild around the already-decoded keymap; no
|
||||
// bridge read, no WAV re-decode) published through the same tail-preserving drain-slot swap,
|
||||
// so changing polyphony / mode / the retrigger toggle never cuts a ringing tail.
|
||||
// Voice-system parameters (per-instance), guarded by voiceParamsMutex_, not read on the
|
||||
// audio thread — each setter rebuilds via rebuildVoiceEngine (already-decoded keymap, no
|
||||
// bridge/WAV re-read) through the same drain-slot swap, so a change never cuts a tail.
|
||||
int voiceCount();
|
||||
void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount
|
||||
VoiceMode voiceMode();
|
||||
@@ -243,251 +174,160 @@ public:
|
||||
MonoTrigger monoTrigger();
|
||||
void setMonoTrigger(MonoTrigger trigger);
|
||||
|
||||
// --- FB1 post-mixer master gain (per-instance, persisted in component state v8) ---------
|
||||
// LINEAR gain in [0, masterGainMaxLinear()] (0.0 = -inf/true silence, 1.0 = unity, cap =
|
||||
// +24 dB; the pure master_gain module owns the dB knob taper). Held in an atomic so the
|
||||
// audio thread applies it with ONE relaxed load per block as a post-sum multiply over the
|
||||
// rendered output (engine + drain + preview) — no lock, no rebuild, no per-voice cost.
|
||||
// Written by the editor's Gain knob (UI thread) and setState; read by getState + process().
|
||||
// Post-mixer master gain, linear in [0, masterGainMaxLinear()] (0 = true silence, 1 =
|
||||
// unity, cap +24 dB). Atomic — the audio thread applies it as a per-block post-sum
|
||||
// multiply, no lock, no rebuild.
|
||||
double masterGainLinear() const {
|
||||
return static_cast<double>(masterGain_.load(std::memory_order_relaxed));
|
||||
}
|
||||
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
|
||||
|
||||
// Fire a one-shot PREVIEW note-on / note-off through the live instrument's MAIN
|
||||
// VoiceEngine — the SAME noteOn/noteOff calls host MIDI takes, so a preview is a REAL
|
||||
// voice: it counts against the voice count, can steal / be stolen, and respects
|
||||
// Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's
|
||||
// isolation — preview must obey voicing). The editor posts the loaded capture's /
|
||||
// selected zone's ROOT note (plays at unity); previewNoteOn plays it at the current
|
||||
// previewVelocity() (the velocity curve applies); previewNoteOff releases it (Gate) —
|
||||
// Trigger zones ignore note-off and play through. OFF the audio thread (the editor's
|
||||
// preview-trigger button, UI thread); the request is handed to process() via a
|
||||
// lock-free single-slot mailbox drained at block start — no allocation, no lock on the
|
||||
// audio thread. A momentary button (down = on, up = off) reads as a natural key press.
|
||||
// This is PLAYBACK ONLY: it never captures, never inserts a timeline item.
|
||||
// Fires a one-shot preview note-on/off through the live VoiceEngine — the same
|
||||
// noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice
|
||||
// count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio
|
||||
// thread; handed to process() via a lock-free single-slot mailbox drained at block
|
||||
// start. Never captures, never inserts a timeline item.
|
||||
void previewNoteOn(int note);
|
||||
void previewNoteOff(int note);
|
||||
|
||||
// The instance-owned sample refs (pS self-contained playback): a snapshot copy for the
|
||||
// editor (waveform/loop-intrinsic fallback when the bank blob is not readable). UI
|
||||
// thread; guarded by refsMutex_.
|
||||
// Snapshot copy of the instance-owned sample refs, for the editor's waveform/loop
|
||||
// fallback when the bank blob is unreadable. Guarded by refsMutex_.
|
||||
SampleRefs sampleRefs();
|
||||
|
||||
private:
|
||||
// Phase S drain retirement (FA1-review Major #2): if process() has published that the
|
||||
// CURRENT drain instrument is fully idle (every engine voice silent),
|
||||
// move it out of the drain slot into the graveyard and prune — so an edited-away snapshot
|
||||
// stops costing resident memory as soon as its tails die, instead of squatting in the slot
|
||||
// until the NEXT reload. Off the audio thread only (takes reloadMutex_); driven from
|
||||
// pollBankSync's UI-timer tick (the same cadence that drives reloads — an idle drain with
|
||||
// no editor open simply waits for the next reload/deactivate, exactly the pre-fix bound).
|
||||
// Safe against a racing process(): idleness is monotone (the drain receives no note-ons)
|
||||
// and the published value names the drain's OWN installedAt, so a stale publication about
|
||||
// an OLDER drain can never retire a newer one; the graveyard prune's monotone-generation
|
||||
// proof (see below) covers the free.
|
||||
// If process() published that the drain instrument is fully idle, move it into the
|
||||
// graveyard and prune — so an edited-away snapshot stops costing memory as soon as its
|
||||
// tails die. Off the audio thread only (driven by pollBankSync); safe against a racing
|
||||
// process() because idleness is monotone and the publication names the drain's own
|
||||
// installedAt (a stale value can never retire a newer occupant).
|
||||
void retireIdleDrain();
|
||||
|
||||
// Phase S voice-param LIGHT rebuild (voice-review Major #3): rebuild the engine
|
||||
// around a COPY of the LIVE instrument's already-decoded Keymap — no bridge read, no
|
||||
// filesystem, no WAV re-decode — and publish through the same tail-preserving drain-slot
|
||||
// swap as a full reload. A polyphony/mode/trigger change touches no audio data, so the
|
||||
// full reloadInstrument (which re-decodes every zone WAV from disk on the UI thread) was
|
||||
// pure waste — a visible UI stall on a many-zone instrument. Copying the keymap is safe:
|
||||
// it is immutable after construction and, under reloadMutex_, the live instrument can
|
||||
// neither be swapped nor freed while we read it. When nothing is loaded this is a no-op —
|
||||
// the new params bake into the next real reload. Off the audio thread only.
|
||||
// Light voice-param rebuild: rebuilds the engine around a copy of the live instrument's
|
||||
// already-decoded Keymap (no bridge/disk) and publishes through the same drain-slot
|
||||
// swap as a full reload. No-op when nothing is loaded. Off the audio thread only.
|
||||
void rebuildVoiceEngine();
|
||||
|
||||
// The pre-v10 LEGACY LIFT gate (#A): true when a lift attempt this tick could make
|
||||
// progress. Latches legacyLiftConcluded_ on a Stale proof (see the member below); the
|
||||
// pure decision itself is sample_map's legacyLiftDecision. Off the audio thread only
|
||||
// (bridge read + bank parse).
|
||||
// Pre-v10 legacy-lift gate: true when a lift attempt this tick could make progress
|
||||
// (see legacyLiftConcluded_). Off the audio thread only (bridge read + bank parse).
|
||||
bool legacyLiftShouldRun();
|
||||
|
||||
// Publish `built` (null = install silence) into live_: prune the graveyard by the last
|
||||
// process()-published generation, swap `built` into live_, displace the previous live into
|
||||
// the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES
|
||||
// reloadMutex_ held — factored out so reloadInstrument and rebuildVoiceEngine share the ONE
|
||||
// safety-critical swap dance (see the handoff proof below).
|
||||
// Publishes `built` (null = install silence) into live_: prunes the graveyard by the
|
||||
// last process()-published generation, swaps `built` into live_, displaces the previous
|
||||
// live into the drain slot, and parks the evicted drain instrument in the graveyard.
|
||||
// Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine.
|
||||
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
|
||||
|
||||
// pS-usage: publish this instance's held captures to its per-instance ext-state key
|
||||
// ("rsusage_<instanceGuid>") so the extension's prune counts them as referenced — a
|
||||
// capture a live instance holds can never be pruned. Called at the end of every
|
||||
// reloadInstrument (the ONE choke point every play-set change funnels through:
|
||||
// selection change, zone edits, assignment consume, bank refresh, setState load), so
|
||||
// publishing is EAGER and needs no timer — a closed-editor instance's record is
|
||||
// already in ext-state from its last change/load. OFF THE AUDIO THREAD only (bridge
|
||||
// calls). Mints instanceGuid_ on first need; RE-mints when planUsagePublish detects
|
||||
// this state was cloned onto another track (FX copy / track duplication). Idempotent
|
||||
// on an unchanged play-set (skipWrite). `refs`/`ids` are reloadInstrument's own
|
||||
// snapshot — the refs table and the id set the instance currently plays.
|
||||
// Publishes this instance's held captures to its per-instance ext-state key
|
||||
// ("rsusage_<instanceGuid>") so the extension's prune can never reclaim them. Called at
|
||||
// the tail of every reloadInstrument, off the audio thread. Mints instanceGuid_ on
|
||||
// first need; re-mints on a detected clone (FX copy / track duplication).
|
||||
void publishUsage(const SampleRefs& refs, const std::vector<std::string>& ids);
|
||||
|
||||
ReaperBridge bridge_;
|
||||
|
||||
// --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) --
|
||||
// process() atomically loads `live_` AND `draining_` at block start and marshals/renders
|
||||
// against them — two atomic acquires, no lock, no free on the audio thread.
|
||||
// --- The audio-thread handoff (drain slot) ---
|
||||
// process() atomically loads live_ + draining_ at block start (two acquires, no lock).
|
||||
// reloadInstrument() (off-thread, serialized by reloadMutex_) swaps a new build into
|
||||
// live_; the displaced instrument moves to draining_, where process() keeps rendering
|
||||
// its already-sounding voices (and routes note-offs to it) so a reload never cuts a
|
||||
// ringing note — new note-ons go only to live_. The instrument evicted from draining_
|
||||
// (two reloads old) parks in graveyard_ for reclaim.
|
||||
//
|
||||
// reloadInstrument() (off-thread, serialized by reloadMutex_) builds a new
|
||||
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
|
||||
// NOT freed and NOT silenced: it moves into `draining_`, where process() keeps
|
||||
// rendering its already-sounding voices (and routes note-offs to it) so a reload —
|
||||
// a curve/param edit, a bank-generation refresh, an applied assignment — never cuts a
|
||||
// ringing note (FA1, bug 3b). New note-ons go ONLY to the live instrument, so the next
|
||||
// trigger plays the new state. The instrument evicted FROM the drain slot (two reloads
|
||||
// old) is parked in `graveyard_` for reclaim — a rapid second reload hard-cuts only the
|
||||
// oldest edit's tails (bounded compromise, documented).
|
||||
// Reclaim: process() publishes the minimum installedAt it holds via processGeneration_
|
||||
// (one relaxed store); the reload path frees graveyard entries older than that. Safe
|
||||
// because both slots are monotone in installedAt, so the published minimum is monotone
|
||||
// and an entry only reaches the graveyard after leaving both slots under reloadMutex_ —
|
||||
// an entry below the published minimum can never be loaded again.
|
||||
//
|
||||
// Bounded reclaim: process() publishes the MINIMUM installedAt over the (non-null)
|
||||
// pointers it holds this block via processGeneration_ — a single atomic store, RT-safe.
|
||||
// The reload path frees graveyard entries whose installedAt < seen (the last published
|
||||
// value).
|
||||
//
|
||||
// Safety argument: both slots are monotone in installedAt over time (live_ receives
|
||||
// successively newer builds; draining_ receives successively newer displaced lives), so
|
||||
// the published minimum is monotone across blocks, and any future process() load yields
|
||||
// installedAt >= seen. An entry only reaches the graveyard by leaving BOTH slots
|
||||
// (single-writer under reloadMutex_), so a graveyard entry with installedAt < seen can
|
||||
// never again be loaded and is not currently held — freeing it is safe. process()
|
||||
// publishes BEFORE rendering, so the pointers it renders with are covered by the value
|
||||
// the pruner reads (a stale lower read is merely conservative).
|
||||
//
|
||||
// The graveyard's upper bound is the number of reloads since process last ran
|
||||
// (typically 0–1 in normal use). Remaining entries drain at setActive(false) /
|
||||
// terminate(), when the host guarantees process is stopped.
|
||||
// Graveyard upper bound: reloads since process last ran (typically 0-1). Remaining
|
||||
// entries drain at setActive(false) / terminate(), when process is guaranteed stopped.
|
||||
std::atomic<LoadedInstrument*> live_{nullptr};
|
||||
std::atomic<LoadedInstrument*> draining_{nullptr}; // displaced instrument still rendering its tails
|
||||
std::atomic<std::uint64_t> reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process)
|
||||
std::atomic<std::uint64_t> processGeneration_{0}; // min installedAt held by process (written on audio thread, read off-thread)
|
||||
// Phase S: the installedAt of the drain instrument process() last observed FULLY IDLE
|
||||
// (every engine voice silent; 0 = none / the current drain still sounds). Written relaxed on the audio thread each
|
||||
// block; read by retireIdleDrain() off-thread. Naming the generation (not a bool) closes
|
||||
// the swap race: a publication about an old drain can never retire its successor.
|
||||
// The installedAt of the drain instrument process() last observed fully idle (0 = none /
|
||||
// still sounds). Written relaxed on the audio thread each block; read by retireIdleDrain()
|
||||
// off-thread. Naming the generation (not a bool) closes the swap race: a publication about
|
||||
// an old drain can never retire its successor.
|
||||
std::atomic<std::uint64_t> drainIdleGeneration_{0};
|
||||
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
|
||||
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
||||
|
||||
// The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence).
|
||||
// Off-thread only; a small mutex guards the string against a getState/editor race. NOT
|
||||
// read on the audio thread.
|
||||
// The single-capture selection id ("" = no pick -> silence). Off-thread only, not read
|
||||
// on the audio thread.
|
||||
std::mutex selectionMutex_;
|
||||
std::string selectedSampleId_;
|
||||
|
||||
// The performance map (Tier 1: the instrument's owned zoned keymap). Off-thread only;
|
||||
// guarded against a getState/editor race. NOT read on the audio thread — reloadInstrument
|
||||
// bakes it into the LoadedInstrument's Keymap under the reload lock.
|
||||
// The performance map (zoned keymap). Off-thread only; reloadInstrument bakes it into
|
||||
// the Keymap under the reload lock, never read directly on the audio thread.
|
||||
std::mutex performanceMutex_;
|
||||
PerformanceMap performanceMap_;
|
||||
|
||||
// The instance-OWNED sample refs (pS self-contained playback): the path + intrinsics
|
||||
// per referenced bank sample that setState restores, reloadInstrument resolves/decodes
|
||||
// from, and getState persists (v10). Refreshed opportunistically from the bank blob
|
||||
// when it is readable; NEVER a bank dependency for playback. Off-thread only (UI +
|
||||
// load/save + reload); guarded against a getState/reload race. NOT read on the audio
|
||||
// thread.
|
||||
// Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed
|
||||
// opportunistically from the bank blob when readable; never a bank dependency for
|
||||
// playback. Off-thread only.
|
||||
std::mutex refsMutex_;
|
||||
SampleRefs sampleRefs_;
|
||||
|
||||
// pS-usage publish identity + lifetime nonce (see publishUsage). instanceGuid_ is
|
||||
// the persisted per-instance identity (ComponentState v11; empty until first
|
||||
// publish); usageNonce_ is THIS incarnation's per-LIFETIME owner nonce, carried
|
||||
// INSIDE the published wire (UsageRecord.ownerNonce) — planUsagePublish's exact
|
||||
// ownership discriminator between "my own write" (clean replace) and "a foreign
|
||||
// writer" (union / re-mint). NEVER persisted: a persisted nonce would clone with
|
||||
// the state on FX copy, and two same-track copies converging on byte-identical
|
||||
// wires is exactly the ambiguity the nonce exists to break (a wire-equality
|
||||
// discriminator let sibling A clean-replace over sibling B's still-held paths —
|
||||
// the delete direction). Minted lazily on first publish; cleared on setState (a
|
||||
// restored blob is a new lifetime). Guarded by usageMutex_ (publish runs under
|
||||
// reloadMutex_ but getState/setState do not).
|
||||
// Usage-publish identity (see publishUsage). instanceGuid_ is the persisted per-instance
|
||||
// identity; usageNonce_ is this incarnation's per-lifetime owner nonce (never persisted —
|
||||
// a persisted nonce would clone with the state on FX copy, letting a sibling clean-
|
||||
// replace over another's held paths). Minted lazily; cleared on setState.
|
||||
std::mutex usageMutex_;
|
||||
std::string instanceGuid_;
|
||||
std::string usageNonce_;
|
||||
|
||||
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadInstrument);
|
||||
// guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read
|
||||
// on the audio thread — process renders against the host's negotiated output channel count.
|
||||
// channelModeExplicit_ (GA, persisted v9): false = the mode is an un-touched default that
|
||||
// reloadInstrument may auto-default from the loaded capture's channel count; true = the user
|
||||
// deliberately toggled the mode (setChannelMode latches it) and it is never fought.
|
||||
// Per-instance channel mode, default Mono; not read on the audio thread (process
|
||||
// renders against the host's negotiated channel count). channelModeExplicit_: false =
|
||||
// reloadInstrument may auto-default the mode from the loaded capture; true = the user
|
||||
// deliberately toggled it (never fought thereafter).
|
||||
std::mutex channelModeMutex_;
|
||||
ChannelMode channelMode_ = ChannelMode::Mono;
|
||||
bool channelModeExplicit_ = false;
|
||||
|
||||
// The last assignment-request generation this instance CONSUMED (S8 reader). Persisted in
|
||||
// component state (v5) so a re-open does not re-apply a request the user already got and
|
||||
// then changed away from. Written by pollBankSync (UI/timer thread) and getState; read by
|
||||
// pollBankSync + getState; seeded by setState. Guarded against a getState/poll race. NEVER
|
||||
// read on the audio thread. Default 0 -> a genuinely new first assign (gen >= 1) applies.
|
||||
// The last assignment-request generation consumed, persisted so a re-open does not
|
||||
// re-apply a stale request. Default 0 -> a genuinely new first assign (gen >= 1) applies.
|
||||
std::mutex assignMarkerMutex_;
|
||||
std::int64_t lastConsumedAssignGeneration_ = 0;
|
||||
|
||||
// The bank generation this instance last SAW (S9 reader). UI/timer-thread only (pollBankSync
|
||||
// is the sole reader/writer) — no mutex needed, and it is NOT persisted. Initialized to a
|
||||
// -1 SENTINEL (no real generation can be negative — parseBankGeneration yields >= 0) so the
|
||||
// FIRST poll after an editor open BASELINES the seen value without a redundant reload
|
||||
// (setState already loaded the instrument from the OWNED refs); a subsequent generation
|
||||
// CHANGE then drives the reload. Since pS there is NO reopen-heal here: playback never
|
||||
// depends on this poll — a v10 blob plays from its own refs at setState time. Besides a
|
||||
// generation change, pollBankSync reloads only for an APPLIED S8 assignment and for the
|
||||
// pre-v10 LEGACY LIFT. NOT read on the audio thread.
|
||||
// The bank generation this instance last saw. UI/timer-thread only (pollBankSync's sole
|
||||
// reader/writer), not persisted. -1 sentinel baselines the first poll without a
|
||||
// redundant reload; a later generation change then drives the reload.
|
||||
std::int64_t lastSeenBankGeneration_ = -1;
|
||||
|
||||
// The pre-v10 LEGACY LIFT's terminating latch (#A): set once legacyLiftShouldRun proves
|
||||
// the referenced ids STALE against a readable bank blob (LegacyLiftDecision::Stale) —
|
||||
// there is nothing to lift, so the lift stops re-firing (the steady state is one relaxed
|
||||
// load per tick, no bank read). Reset by setState (a new blob = new facts). NOT consulted
|
||||
// by the genChanged/applied reload paths, so a later bank change that re-introduces an id
|
||||
// (e.g. an extension-side undo) still refreshes the refs — the latch only gates the lift.
|
||||
// Atomic: written on the UI-timer thread (pollBankSync) and the host load thread (setState).
|
||||
// Legacy-lift terminating latch: set once legacyLiftShouldRun proves the referenced ids
|
||||
// stale against a readable bank blob, so the lift stops re-firing every tick. Reset by
|
||||
// setState (a new blob = new facts).
|
||||
std::atomic<bool> legacyLiftConcluded_{false};
|
||||
|
||||
// S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the
|
||||
// user's chosen strike velocity survives a project save/reload. Since Wave 2 the Sample-view
|
||||
// velocity knob writes it on the UI thread, so it is guarded by previewMutex_; setState and
|
||||
// getState (load/save thread) share the same guard. Default kPreviewVelocityDefault (64). NOT
|
||||
// read on the audio thread.
|
||||
// Preview-trigger velocity (MIDI 1..127, persisted). Default kPreviewVelocityDefault
|
||||
// (64). Not read on the audio thread.
|
||||
std::mutex previewMutex_;
|
||||
std::uint8_t previewVelocity_ = kPreviewVelocityDefault;
|
||||
|
||||
// Phase S voice-system parameters (per-instance, persisted in component state v7). Off-thread
|
||||
// only (UI voice deck + getState/setState + reloadInstrument); guarded against a getState/editor
|
||||
// race. Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior. NOT read on the audio
|
||||
// thread — reloadInstrument bakes them into the LoadedInstrument's engine off-thread.
|
||||
// Voice-system parameters (per-instance, persisted). Defaults {16, Poly, Retrigger}.
|
||||
// Not read on the audio thread — reloadInstrument bakes them into the engine off-thread.
|
||||
std::mutex voiceParamsMutex_;
|
||||
int voiceCount_ = kDefaultVoiceCount;
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
|
||||
// FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free
|
||||
// atomic — the target the UI thread writes; the audio thread ramps gainCurrent_ toward
|
||||
// it per-sample each block (linear interpolation, ~20 ms wall-clock at every host rate)
|
||||
// so sudden knob moves produce no zipper noise and the true-zero bottom causes no click.
|
||||
// Post-mixer master gain (linear, persisted). Lock-free atomic target; the audio thread
|
||||
// ramps gainCurrent_ toward it per-sample (~20 ms wall-clock at every host rate) so
|
||||
// knob moves produce no zipper noise.
|
||||
std::atomic<float> masterGain_{1.0f};
|
||||
// The audio-thread running gain value: tracks masterGain_ across blocks, stepping at
|
||||
// most gainRampStep_ per sample toward the target. Starts at unity (pre-FB1 default).
|
||||
// Written and read exclusively on the audio thread — no atomics needed.
|
||||
// Audio-thread running gain value, stepping at most gainRampStep_ per sample toward the
|
||||
// target. Written/read exclusively on the audio thread — no atomics needed.
|
||||
float gainCurrent_ = 1.0f;
|
||||
// T3-01: the per-sample ramp step, derived from kGainRampSeconds (20 ms wall-clock)
|
||||
// against the live host rate in setupProcessing — never a baked-in rate. The default is
|
||||
// the 48 kHz value so behavior before the first setupProcessing is unchanged. Written in
|
||||
// setupProcessing (host-serialized against process), read on the audio thread.
|
||||
// Per-sample ramp step derived from kGainRampSeconds against the live host rate in
|
||||
// setupProcessing — never a baked-in rate. Default is the 48 kHz value.
|
||||
float gainRampStep_ = 1.0f / 960.0f;
|
||||
|
||||
// --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) ---------
|
||||
// The editor's preview-trigger button posts a note-on/off request from the UI thread; process()
|
||||
// drains it at block start and drives the live instrument's MAIN VoiceEngine — the same
|
||||
// noteOn/noteOff host MIDI takes, so the preview obeys voicing. ONE slot per direction, each a packed
|
||||
// request whose high bits are a monotonically-incrementing sequence so process() detects a NEW
|
||||
// request by comparing against the last sequence it consumed (never re-firing a stale one). The
|
||||
// low 8 bits carry the note (on) / note (off); the on request also carries the velocity in the
|
||||
// next 8 bits, latched at post time so the audio thread reads no shared velocity field. A single
|
||||
// relaxed atomic load per block on the audio thread — RT-safe (no alloc, no lock).
|
||||
// packed = (seq << 16) | (velocity << 8) | note [note-on]
|
||||
// packed = (seq << 16) | note [note-off]
|
||||
// --- Preview-trigger mailbox (off-thread -> audio thread, lock-free) -----------------
|
||||
// One slot per direction, packed as (seq << 16) | (velocity << 8) | note [on] or
|
||||
// (seq << 16) | note [off]. process() detects a new request by comparing the packed
|
||||
// sequence against the last one consumed — a single relaxed atomic load per block,
|
||||
// RT-safe (no alloc, no lock).
|
||||
std::atomic<std::uint32_t> previewOnRequest_{0}; // 0 = no request posted yet
|
||||
std::atomic<std::uint32_t> previewOffRequest_{0};
|
||||
std::uint16_t previewOnSeq_ = 0; // UI-thread post counter (never 0 after first post)
|
||||
@@ -495,22 +335,17 @@ private:
|
||||
std::uint16_t previewOnConsumed_ = 0; // audio-thread: last on-seq fired
|
||||
std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired
|
||||
|
||||
// Latched from setupProcessing so setActive/reload can size against it. Read
|
||||
// off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate
|
||||
// before any audio, and reloadInstrument guards on it before use.
|
||||
// Latched from setupProcessing; 0.0 is explicitly invalid (reloadInstrument guards on it).
|
||||
double sampleRate_ = 0.0;
|
||||
Steinberg::int32 maxBlockSize_ = 4096;
|
||||
|
||||
// --- S6 embedded TCP/MCP UI ---------------------------------------------
|
||||
// The embed shell (IReaperUIEmbedInterface), created lazily on the first queryInterface
|
||||
// and owned here for the processor's lifetime. REAPER borrows AddRef'd references from
|
||||
// queryInterface; the shell's refcount is a no-op because THIS unique_ptr governs its
|
||||
// destruction (the processor always outlives the borrowed references).
|
||||
// The embed shell, created lazily on the first queryInterface and owned here for the
|
||||
// processor's lifetime; REAPER's borrowed AddRef'd references are outlived by this
|
||||
// unique_ptr, so its own refcount is a no-op.
|
||||
std::unique_ptr<ReaSamplerEmbed> embed_;
|
||||
|
||||
// The per-block mono peak (0..1+) the audio thread stores relaxed; the embed strip's
|
||||
// level indicator reads it via embedActivityLevel(). Advisory only — a plain atomic,
|
||||
// no ordering coupling, never guarded by a lock the audio thread touches.
|
||||
// Per-block mono peak the audio thread stores relaxed; embedActivityLevel() reads it
|
||||
// for the embed strip's level indicator. Advisory only.
|
||||
std::atomic<float> embedPeak_{0.f};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
// reasampler_vst.h — shared identity constants for the ReaSampler VST3 instrument
|
||||
// (Phase S). One place for the plugin's class UID, name, vendor, and version so the
|
||||
// processor, factory, and editor agree.
|
||||
//
|
||||
// A class UID is FOREVER-STABLE once shipped: a REAPER project that instantiates this
|
||||
// instrument records the UID, so changing it orphans every saved instance. Minted once;
|
||||
// do not regenerate.
|
||||
//
|
||||
// CHANNEL ISOLATION (S18, beta-in-isolation — the instrument-side companion to V4). Just
|
||||
// as V4 gave the extension a per-channel ext-state namespace / command-id family / dock
|
||||
// ident, S18 gives the VST3 instrument a per-channel PLUGIN IDENTITY: its class UID, its
|
||||
// on-disk filename, and its display name all fork by the ONE channel bit
|
||||
// (REASAMPLER_CHANNEL_IS_BETA, from version_generated.h). ONE class per binary — the bit
|
||||
// selects which UID compiles into the single DEF_CLASS2, so a beta build carries only the
|
||||
// beta identity and can never present the stable one (mirrors V4's fully-isolated-binary
|
||||
// philosophy). The two UIDs below are BOTH frozen forever; the filename + display name
|
||||
// derive from app_version's vstOutputName()/vstPluginName() (this header owns only the
|
||||
// binary UID identity — the string identity lives in the pure module).
|
||||
// reasampler_vst.h — shared identity constants for the ReaSampler VST3 instrument: the
|
||||
// plugin's class UID, vendor name/URL/email, so the processor, factory, and editor agree.
|
||||
// A class UID is FOREVER-STABLE once shipped (see this directory's CLAUDE.md) — minted
|
||||
// once, never regenerated. Filename + display name are channel-derived from app_version's
|
||||
// string accessors; this header owns only the binary UID identity.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -25,23 +12,16 @@
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// Vendor identity (S-NAME-1, SETTLED 2026-07-26). Shared across channels — V4 kept the
|
||||
// lane-name prefix shared, so shared-where-V4-shares is the default (the channel is carried
|
||||
// by the UID + filename + display fork, not the vendor block).
|
||||
// Vendor identity, shared across channels — the channel is carried by the UID + filename +
|
||||
// display fork, not the vendor block.
|
||||
inline constexpr const char* kVendorName = "ReaSampler";
|
||||
inline constexpr const char* kVendorUrl = "https://github.com/daniel-c-harvey/reasampler";
|
||||
inline constexpr const char* kVendorEmail = "mailto:the.real.daniel.harvey@gmail.com";
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// The two FOREVER-FROZEN VST3 class UIDs — one per channel — live in reasampler_uid.h
|
||||
// (SDK-free, so the extension's pure instrument_drop can render the .vstpreset class-ID
|
||||
// string from the SAME constants without pulling the VST3 SDK). A saved REAPER project
|
||||
// records the UID of the instance it instantiated and rebinds by it on reopen, so each is
|
||||
// a permanent commitment. The channel bit selects which one this binary's factory registers
|
||||
// — one class per binary, never both. The UID selection is the ONLY channel #ifdef in the
|
||||
// VST shell (an INLINE_UID needs literal brace-init tokens, so it cannot route through
|
||||
// app_version's runtime string accessors — reasampler_uid.h owns the binary UID fork,
|
||||
// app_version owns the string fork).
|
||||
// string from the same constants without pulling the VST3 SDK). The channel bit selects
|
||||
// which one this binary's factory registers — one class per binary, never both.
|
||||
|
||||
// The runtime FUID for the class this binary registers — the channel-selected UID.
|
||||
static const Steinberg::FUID kReaSamplerProcessorUID(REASAMPLER_ACTIVE_UID_1,
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
// vst_entry.cpp — the VST3 module class factory (Phase S1). Enumerates the one class
|
||||
// this module offers (the ReaSampler instrument) via the SDK's factory macros. The
|
||||
// Windows module exports — GetPluginFactory (here, via BEGIN_FACTORY) and
|
||||
// InitDll/ExitDll (from the SDK's dllmain.cpp) — are how REAPER discovers and loads a
|
||||
// VST3.
|
||||
//
|
||||
// VERIFIED (corrects §1a's "experienced estimate" flags on export names + macros,
|
||||
// against vendor/vst3sdk/public.sdk/source/main/):
|
||||
// * Windows exports: InitDll / ExitDll (SMTG_EXPORT_SYMBOL, in dllmain.cpp) +
|
||||
// GetPluginFactory (SMTG_EXPORT_SYMBOL IPluginFactory* PLUGIN_API, emitted by the
|
||||
// BEGIN_FACTORY macro). The plug-in must provide InitModule/DeinitModule — supplied
|
||||
// here by linking moduleinit.cpp (the SDK's default one-time init/term).
|
||||
// * Factory macros: BEGIN_FACTORY(vendor,url,email,flags) / DEF_CLASS2(...) /
|
||||
// END_FACTORY — exact spellings from pluginfactory.h.
|
||||
// * Instrument subcategory string: "Instrument|Synth|Sampler"
|
||||
// (PlugType::kInstrumentSynthSampler, ivstaudioprocessor.h).
|
||||
// * classFlags = 0 for a SingleComponentEffect (non-distributable), matching the
|
||||
// AGain example.
|
||||
// vst_entry.cpp — the VST3 module class factory. Enumerates the one class this module
|
||||
// offers via the SDK's factory macros. Windows module exports — GetPluginFactory (here,
|
||||
// via BEGIN_FACTORY) and InitDll/ExitDll (SDK's dllmain.cpp) — are how REAPER discovers
|
||||
// and loads a VST3. Verified against vendor/vst3sdk/public.sdk/source/main/: the plug-in
|
||||
// must supply InitModule/DeinitModule (linked here via moduleinit.cpp). classFlags = 0 for
|
||||
// a SingleComponentEffect (non-distributable), matching the AGain example.
|
||||
|
||||
#include "public.sdk/source/main/pluginfactory.h"
|
||||
|
||||
@@ -26,23 +14,16 @@
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
#include "shell/instrument/reasampler_vst.h" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*)
|
||||
|
||||
// CHANNEL PAIRING INVARIANT (S18). The instrument's PLUGIN identity forks by the ONE channel
|
||||
// bit (REASAMPLER_CHANNEL_IS_BETA — the class UID selected in reasampler_vst.h, the filename
|
||||
// + display name in app_version). Its DATA identity forks by the SAME bit, one layer down:
|
||||
// ext_keys.h's kProjExtNamespace() delegates to app_version::extStateNamespace(), so a beta
|
||||
// binary reads "reasampler_beta". Both derive from that one bit, so a beta VST can only ever
|
||||
// talk to the beta extension.
|
||||
// The instrument's plugin identity (UID + filename + display) and its data identity
|
||||
// (ext_keys.h's kProjExtNamespace(), delegating to app_version::extStateNamespace()) both
|
||||
// fork from the one REASAMPLER_CHANNEL_IS_BETA bit, so a beta VST can only ever talk to
|
||||
// the beta extension.
|
||||
//
|
||||
// The guard below pins the two forks together so a refactor cannot split them. It asserts
|
||||
// that the CLASS UID this factory registers (REASAMPLER_ACTIVE_UID_1, selected by the #if in
|
||||
// reasampler_vst.h) is the UID that matches THIS binary's channel bit. If someone edited that
|
||||
// #if to pick the wrong branch — registering the stable UID in a beta build, or vice versa —
|
||||
// the instrument's identity would diverge from the namespace ext_keys reads (a beta-named
|
||||
// plugin presenting the stable UID, or reading the stable banks under a beta identity). That
|
||||
// is exactly the silent split the invariant forbids, and it breaks the build here instead.
|
||||
// (The namespace itself is a runtime accessor — .c_str() on a channel-selected string — so
|
||||
// the couplable compile-time fact is the UID selection, not the namespace value; the
|
||||
// app_version_tests pin the namespace string per channel.)
|
||||
// The guard below pins the two forks together so a refactor cannot split them: it asserts
|
||||
// the class UID this factory registers matches this binary's channel bit. If the #if in
|
||||
// reasampler_vst.h picked the wrong branch, the instrument's identity would diverge from
|
||||
// the namespace ext_keys reads (a beta-named plugin presenting the stable UID, or vice
|
||||
// versa) — this breaks the build instead of shipping that silent split.
|
||||
#if REASAMPLER_CHANNEL_IS_BETA
|
||||
static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_BETA_1 &&
|
||||
REASAMPLER_ACTIVE_UID_2 == REASAMPLER_PROC_UID_BETA_2 &&
|
||||
@@ -62,13 +43,9 @@ static_assert(REASAMPLER_ACTIVE_UID_1 == REASAMPLER_PROC_UID_1 &&
|
||||
BEGIN_FACTORY(reasampler::vst::kVendorName, reasampler::vst::kVendorUrl,
|
||||
reasampler::vst::kVendorEmail, Steinberg::PFactoryInfo::kNoFlags)
|
||||
|
||||
// The display name and version are channel-derived from app_version — sourced here, not
|
||||
// as literals. DEF_CLASS2 expands inside GetPluginFactory() and PClassInfo2's constructor
|
||||
// copies the char* into its own fixed buffer at that runtime call, so .c_str() on the
|
||||
// accessors' static-storage strings is valid (no dangling — the refs outlive the copy).
|
||||
// vstPluginName(): "ReaSampler 9000" / "ReaSampler 9000 beta" (live literals in
|
||||
// app_version.cpp). appVersion(): the configured version string / that string plus
|
||||
// "-beta" (the -beta render V4 already yields on beta).
|
||||
// Display name + version are channel-derived from app_version, not literals. DEF_CLASS2
|
||||
// expands inside GetPluginFactory(); PClassInfo2's constructor copies the char* into its
|
||||
// own buffer at that call, so .c_str() on the accessors' static-storage strings is valid.
|
||||
DEF_CLASS2(INLINE_UID(REASAMPLER_ACTIVE_UID_1, REASAMPLER_ACTIVE_UID_2,
|
||||
REASAMPLER_ACTIVE_UID_3, REASAMPLER_ACTIVE_UID_4),
|
||||
Steinberg::PClassInfo::kManyInstances, // cardinality
|
||||
|
||||
Reference in New Issue
Block a user