Cut shell/instrument comment bloat ~34% (comments only, zero code change)

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