Q-W2v: split VST god-modules — editor 8 face-axis TUs (+pure layout hoist), processor 3 TUs, component_state_io codec split (extension drops the voice engine), zone_params.h, core/wire putLE; formats frozen, 61/61 green

This commit is contained in:
2026-07-29 10:56:09 -04:00
parent 9d5783453c
commit ea86f540b8
37 changed files with 6202 additions and 5352 deletions
+397
View File
@@ -0,0 +1,397 @@
// editor_controls.cpp — the ReaSamplerEditor's PARAMETER PLUMBING (Q-W2v split of
// reasampler_editor.cpp, T4-11): the control-value domain maps (controlValue /
// applyControl — seconds/fraction/frames <-> normalized 0..1), the r11 knob-deck
// group descriptors + control-id<->value binding, the S-VIEW-3 envelope pack/unpack
// (the TRIGGER SEAM converter), the curve-popup target resolution, and applyZoneControl.
// Value logic only — no painting, no window plumbing.
#include "shell/instrument/reasampler_editor.h"
#include <algorithm>
#include <cstdint>
#include <cstdio> // snprintf (deck value labels)
#include <string>
#include <vector>
#include "core/instrument/engine/master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1)
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters (S-VIEW-3)
#include "core/util/clamp01.h"
#include "shell/instrument/editor_internal.h" // DeckGroup ids
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::instrument::map; // ZonePlaySeconds vocabulary + trigger_seam converters
using instrument::engine::formatMasterGainLabel;
using instrument::engine::masterGainLinearFromNorm;
using instrument::engine::masterGainNormFromLinear;
using util::clamp01;
namespace {
// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is
// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span
// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build
// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out)
// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is
// settled-correct and unchanged), but the knob's FULL-SCALE THROW is a wall-clock intent —
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames(), Q-W0 T3-03; the
// prior 88200-frame constant baked 2 s x 44.1 kHz into src/, against the no-hardcoded-rate
// ruling). Build-time residual — one place to retune; not persisted.
constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds)
constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock)
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%)
} // namespace
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
// the rate-resolved frames ceiling (T3-03). Two domains, kept explicit so neither leaks a rate.
// A stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01
// pins it) and gets rewritten down on the next knob touch — deliberate, matching the old
// fixed-ceiling clamp behavior in kind, just rate-dependent now instead of fixed at 88200.
const double fadeMax = fadeMaxFrames();
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
const auto framesToNorm = [fadeMax](std::int64_t f) {
// Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves.
return fadeMax > 0.0 ? clamp01(static_cast<double>(f) / fadeMax) : 0.0;
};
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds);
case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds);
case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds);
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds);
case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction);
case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames);
case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames);
case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds);
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds);
case ParamControl::kPitchEnvDepth:
// Signed depth centered at 0.5 (0.5 == 0 semitones).
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
default: return 0.0;
}
}
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
int segment) const {
const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
const auto normToFrames = [fadeMax](double v) -> std::int64_t {
// Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves.
if (fadeMax <= 0.0) return 0;
return static_cast<std::int64_t>(clamp01(v) * fadeMax + 0.5);
};
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode:
play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
break;
case ParamControl::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
break;
case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break;
case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break;
case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break;
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
case ParamControl::kTrigLength:
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
break;
case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break;
case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break;
case ParamControl::kPitchEnvEnable:
play.pitchEnv.enabled = (segment == 1);
break;
case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break;
case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break;
case ParamControl::kPitchEnvDepth:
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
break;
default: break;
}
}
double ReaSamplerEditor::liveSampleRate() const {
return processor_ ? processor_->sampleRate() : 0.0;
}
double ReaSamplerEditor::fadeMaxFrames() const {
// T3-03: the Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock)
// resolved against the live rate — the SAME time base the envelope overlay already uses
// to place these source-frame fades on screen (totalSeconds = frames / liveSampleRate()),
// and the rate captures are made at (the capture path renders at the project rate).
// Pre-setupProcessing the rate is still 0: rather than substitute a literal rate (the
// exact residue T3-03 removed), bail the same way paintEnvelopeOverlay does (~line 1396) —
// callers treat a <= 0 return as "ceiling unavailable yet" and degrade the knob to inert
// rather than guess a rate. Storage stays SOURCE FRAMES — this resolves the UI ceiling only.
const double rate = liveSampleRate();
if (rate <= 0.0) return 0.0;
return kFadeMaxSeconds * rate;
}
double ReaSamplerEditor::previewVelocity01() const {
if (!processor_) return static_cast<double>(kPreviewVelocityDefault) / 127.0;
return static_cast<double>(processor_->previewVelocity()) / 127.0;
}
std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const {
// The PER-ZONE groups — the deck grammar both surfaces share (FB2: the Zone panel renders
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs).
// Group widths are MODE-INDEPENDENT: AMP ENVELOPE reserves its 5-cell Gate width (Trigger
// leaves two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows
// the neighbouring groups (r11).
std::vector<DeckGroupDesc> out;
{
DeckGroupDesc amp;
amp.id = kGroupAmpEnv;
amp.captionWidth = 78;
amp.captionToggle = {static_cast<int>(ParamControl::kPlayMode), 44};
if (play.playMode == PlayMode::Gate) {
amp.cellIds = {static_cast<int>(ParamControl::kAttack),
static_cast<int>(ParamControl::kHold),
static_cast<int>(ParamControl::kDecay),
static_cast<int>(ParamControl::kSustain),
static_cast<int>(ParamControl::kRelease)};
} else {
// Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out —
// matches the drawn envelope), plus the two reserved blanks.
amp.cellIds = {static_cast<int>(ParamControl::kTrigFadeIn),
static_cast<int>(ParamControl::kTrigLength),
static_cast<int>(ParamControl::kTrigFadeOut), -1, -1};
}
out.push_back(std::move(amp));
}
{
DeckGroupDesc pitch;
pitch.id = kGroupPitch;
pitch.captionWidth = 38;
pitch.captionToggle = {static_cast<int>(ParamControl::kPitchEngine), 48};
pitch.cellIds = {static_cast<int>(ParamControl::kKeyTrack)};
out.push_back(std::move(pitch));
}
{
DeckGroupDesc penv;
penv.id = kGroupPitchEnv;
penv.captionWidth = 58;
penv.captionToggle = {static_cast<int>(ParamControl::kPitchEnvEnable), 32};
penv.cellIds = {static_cast<int>(ParamControl::kPitchEnvAttack),
static_cast<int>(ParamControl::kPitchEnvDecay),
static_cast<int>(ParamControl::kPitchEnvDepth)};
out.push_back(std::move(penv));
}
return out;
}
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const {
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER
// groups. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the
// post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays
// OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs.
std::vector<DeckGroupDesc> out = zoneDeckGroupDescs(play);
{
DeckGroupDesc voice;
voice.id = kGroupVoice;
voice.captionWidth = 38;
voice.captionToggle = {static_cast<int>(ParamControl::kVoiceMode), 40};
voice.cellIds = {static_cast<int>(ParamControl::kVoiceCount)};
voice.rowToggle = {static_cast<int>(ParamControl::kMonoTrigger), 44};
out.push_back(std::move(voice));
}
{
DeckGroupDesc master;
master.id = kGroupMaster;
master.captionWidth = 46;
master.cellIds = {static_cast<int>(ParamControl::kMasterGain)};
out.push_back(std::move(master));
}
return out;
}
double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const {
if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob
switch (static_cast<ParamControl>(id)) {
case ParamControl::kKeyTrack:
return clamp01(zone.keyTrack / kKeyTrackMax);
case ParamControl::kVoiceCount:
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
case ParamControl::kMasterGain:
return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0);
default:
return controlValue(id, zone.play);
}
}
void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
if (!processor_) return;
norm = clamp01(norm);
if (id == -2) {
// Preview velocity: live processor write (persisted per-instance; the setter clamps
// to MIDI 1..127 so the knob's bottom still strikes audibly).
processor_->setPreviewVelocity(static_cast<std::uint8_t>(norm * 127.0 + 0.5));
return;
}
switch (static_cast<ParamControl>(id)) {
case ParamControl::kVoiceCount: {
// Stepped: quantize the continuous drag to the integer count and track it live
// for the label/needle. The actual engine rebuild (setVoiceCount) fires ONCE on
// WM_LBUTTONUP — not per step — so a full drag (~31 steps) costs one rebuild,
// not thirty.
const int count =
kMinVoiceCount +
static_cast<int>(norm * (kMaxVoiceCount - kMinVoiceCount) + 0.5);
voiceCount_ = count;
return;
}
case ParamControl::kMasterGain:
// Post-mixer gain: one atomic store; the audio thread picks it up next block.
processor_->setMasterGainLinear(masterGainLinearFromNorm(norm));
return;
default:
applyZoneControl(zoneIndex, id, norm, 0);
return;
}
}
std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const {
char buf[24];
buf[0] = '\0';
const ZonePlaySeconds& play = zone.play;
switch (id == -2 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
case ParamControl::kAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break;
case ParamControl::kHold:
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.holdSeconds); break;
case ParamControl::kDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.decaySeconds); break;
case ParamControl::kSustain:
snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break;
case ParamControl::kRelease:
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break;
case ParamControl::kTrigLength:
snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break;
case ParamControl::kTrigFadeIn:
snprintf(buf, sizeof(buf), "%lldf",
static_cast<long long>(play.trigger.fadeInFrames)); break;
case ParamControl::kTrigFadeOut:
snprintf(buf, sizeof(buf), "%lldf",
static_cast<long long>(play.trigger.fadeOutFrames)); break;
case ParamControl::kPitchEnvAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break;
case ParamControl::kPitchEnvDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break;
case ParamControl::kPitchEnvDepth:
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
case ParamControl::kKeyTrack:
snprintf(buf, sizeof(buf), "%.0f%%", zone.keyTrack * 100.0); break;
case ParamControl::kVoiceCount:
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
case ParamControl::kMasterGain:
formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break;
default:
// -2 (preview velocity) is labeled at its cluster call site; nothing else here.
break;
}
return std::string(buf);
}
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
// Match the control-panel sliders' own domains so a node drag can never produce a param a
// slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the
// Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use).
EnvClampBounds b;
b.maxAttackSeconds = kEnvTimeMaxSeconds;
b.maxHoldSeconds = kEnvTimeMaxSeconds;
b.maxDecaySeconds = kEnvTimeMaxSeconds;
b.maxReleaseSeconds = kEnvTimeMaxSeconds;
b.maxFadeInFraction = 1.0;
b.maxFadeOutFraction = 1.0;
b.maxLengthFraction = 1.0;
return b;
}
AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const {
AmpEnvelope env;
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
// AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws).
env.attackSeconds = play.adsr.attackSeconds;
env.holdSeconds = play.adsr.holdSeconds;
env.decaySeconds = play.adsr.decaySeconds;
env.sustainLevel = play.adsr.sustainLevel;
env.releaseSeconds = play.adsr.releaseSeconds;
// Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played
// span (the TRIGGER SEAM converter, PACK direction). startFrame is the zone's effective start
// point so the fraction denominator matches the voice's actual post-start span. A zero play
// length yields 0 fractions.
env.lengthFraction = play.trigger.lengthFraction;
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen);
env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen);
return env;
}
void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames,
std::int64_t startFrame, ZonePlaySeconds& play) const {
if (env.mode == EnvMode::Gate) {
play.adsr.attackSeconds = env.attackSeconds;
play.adsr.holdSeconds = env.holdSeconds;
play.adsr.decaySeconds = env.decaySeconds;
play.adsr.sustainLevel = env.sustainLevel;
play.adsr.releaseSeconds = env.releaseSeconds;
} else {
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
// the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's
// effective start point so the frame denominator matches the voice's actual post-start span.
// Keep the same (0,1] floor on lengthFraction the slider path enforces so a zero-length
// trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen);
play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen);
}
}
PerformanceZone ReaSamplerEditor::popupZone() const {
// The zone the popup displays: the Zone surface's SELECTED zone (FB2), else the Sample
// face's one-zone site (a read-only resolve — an edit materializes via popupZoneIndex).
if (view_ == View::kZone && selectedZone_ >= 0 &&
selectedZone_ < static_cast<int>(map_.zones.size())) {
return map_.zones[static_cast<std::size_t>(selectedZone_)];
}
return effectiveSampleZone();
}
int ReaSamplerEditor::popupZoneIndex() {
// The map_.zones index a popup edit lands on, or -1 when there is no valid target. The
// Zone surface never materializes (the button only shows for an explicit selection); the
// Sample face finds-or-materializes the picked id's one-zone site.
if (view_ == View::kZone) {
return (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size()))
? selectedZone_
: -1;
}
return ensureSampleZone();
}
#ifdef _WIN32
void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) {
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
// keyTrack lives on the zone (0..200% over kKeyTrackMax); the slider maps 0..1.
z.keyTrack = clamp01(value) * kKeyTrackMax;
} else {
applyControl(id, z.play, value, segment);
}
}
#endif // _WIN32
} // namespace reasampler::vst
@@ -0,0 +1,440 @@
// editor_input_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE
// input + the hover resolver (Q-W2v split of reasampler_editor.cpp, T4-11): the L3 hover
// resolution across all three faces, the Browse picker's click branch (tabs, cards,
// select-then-confirm, scroll-thumb grab, search focus), the Zone surface's click branch
// (add/delete, strip drags, numeric-entry focus, per-zone deck + curve button), the
// browser wheel scroll, the type-to-filter / note-entry keystrokes, and the S13 degraded
// drop affordance. Windows-only (D5).
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12)
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup (popup hover)
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
#include "core/instrument/map/note_entry.h" // parseNoteEntry (S12 numeric entry)
#include "shell/instrument/editor_internal.h" // curveBoxFromRect (popup node hover)
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui;
using namespace reasampler::instrument::ui;
using namespace reasampler::instrument::map;
// --- Hover resolution (Phase L, L3) ------------------------------------------
//
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
// idle move is free). Mirrors onMouseDown's hit-test order, but read-only. Windows-only.
void ReaSamplerEditor::resolveHover(int x, int y) {
HoverTarget h; // kNone by default
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int hgt = cr.bottom - cr.top;
if (view_ == View::kBrowse) {
const BrowseModal bm = computeBrowseModal(w, hgt);
if (contains(bm.back, x, y)) h = {HoverKind::kBack, -1};
else if (contains(bm.cancel, x, y)) h = {HoverKind::kBrowseCancel, -1};
else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1};
else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1};
else {
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
const int bx = x - bm.content.x;
const int by = y - bm.content.y;
const int tabCount = static_cast<int>(banks_.size()) + 1;
const int tab = filterTabHitTest(bl, tabCount, bx, by);
const int card = (tab >= 0)
? -1
: cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
if (tab >= 0) h = {HoverKind::kFilterTab, tab};
else if (card >= 0) h = {HoverKind::kCard, card};
}
} else if (curvePopupOpen_) { // the r11 curve popup — modal over Sample AND Zone (FB2)
const CurvePopupLayout pl = computeCurvePopup(w, hgt);
if (contains(pl.close, x, y)) {
h = {HoverKind::kPopupClose, -1};
} else if (contains(pl.curveBox, x, y)) {
// A curve node under the pointer lights accent-hot.
const int idx =
popupZone().velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
}
} else if (view_ == View::kZone) {
const Rect back = zoneBackRect(w, hgt);
const Rect content = zoneContentArea(w, hgt);
Rect addR = zoneAddRect(content);
Rect delR = zoneDeleteRect(addR);
if (contains(back, x, y)) {
h = {HoverKind::kBack, -1};
} else if (contains(addR, x, y)) {
h = {HoverKind::kAddZone, -1};
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
h = {HoverKind::kDeleteZone, -1};
} else if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
// FB2: the per-zone knob deck + the mini curve-preview button (the Sample deck's
// hover grammar — knobs light + swap label->value).
if (contains(zonesCurveButton(content), x, y)) {
h = {HoverKind::kCurveButton, -1};
} else {
const ZonePlaySeconds& play =
map_.zones[static_cast<std::size_t>(selectedZone_)].play;
const Rect deckArea = zonesDeckArea(content);
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x,
deckArea.y, deckArea.width);
const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
}
}
} else { // Sample view (home, r11 recomposition)
const PerformanceZone zone = effectiveSampleZone();
const std::vector<DeckGroupDesc> descs = deckGroupDescs(zone.play);
const SampleBands bands =
computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad));
if (contains(bands.navBrowse, x, y)) {
h = {HoverKind::kNavBrowse, -1};
} else if (contains(bands.navZone, x, y)) {
h = {HoverKind::kNavZone, -1};
} else if (selectedId_.empty() && map_.zones.empty()) {
// Empty state — no interactive surfaces beyond the nav.
} else {
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1};
else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1};
else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1};
else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
else if (contains(bands.deck, x, y)) {
// A deck knob/toggle under the pointer: knobs light + swap label->value.
const DeckLayout dl =
layoutDeck(descs, bands.deck.x, bands.deck.y, bands.deck.width);
const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
}
}
}
if (h != hover_) {
hover_ = h;
invalidate();
}
}
// The Browse-modal branch of the mouse-down dispatch (formerly inline in onMouseDown —
// behavior-identical; see editor_input_sample.cpp for the dispatch).
void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) {
const BrowseModal bm = computeBrowseModal(w, h);
if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) {
// Cancel/Back: discard the pending pick, return to Sample unchanged.
browsePendingId_.clear();
searchFocused_ = false;
view_ = View::kSample;
invalidate();
return;
}
if (contains(bm.confirm, x, y)) {
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
if (!browsePendingId_.empty()) {
loadSelection(browsePendingId_);
}
browsePendingId_.clear();
searchFocused_ = false;
view_ = View::kSample;
invalidate();
return;
}
if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; }
searchFocused_ = false;
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
const int bx = x - bm.content.x;
const int by = y - bm.content.y;
const int tabCount = static_cast<int>(banks_.size()) + 1;
const int tab = filterTabHitTest(bl, tabCount, bx, by);
if (tab >= 0) {
activeFilterBankId_ = (tab == 0) ? std::string()
: banks_[static_cast<std::size_t>(tab - 1)].id;
rebuildVisible();
invalidate();
return;
}
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
if (thumb.height > 0 &&
contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y,
thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) {
drag_ = DragKind::kScrollThumb;
dragStartY_ = y;
dragStartScrollOffset_ = scrollOffset_;
return;
}
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
if (card >= 0) {
// Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
const std::string id = visible_[static_cast<std::size_t>(card)].id;
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
loadSelection(id);
browsePendingId_.clear();
lastBrowseClickCard_ = -1;
searchFocused_ = false;
view_ = View::kSample;
invalidate();
} else {
browsePendingId_ = id;
lastBrowseClickCard_ = card;
invalidate();
}
return;
}
lastBrowseClickCard_ = -1;
return;
}
// The Zone-surface branch of the mouse-down dispatch (formerly the tail of onMouseDown —
// behavior-identical; the curve popup is modal over the Zone surface too, FB2).
void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
if (handlePopupMouseDown(w, h, x, y)) return;
const Rect back = zoneBackRect(w, h);
if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; }
const Rect content = zoneContentArea(w, h);
const int pad = 8;
Rect addR = zoneAddRect(content);
if (contains(addR, x, y)) {
// Add a narrow default zone for the picked capture (or the first visible sample as a
// sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id
// already exists (pre-fix bleed survivor), select it rather than appending a duplicate
// (mirrors the upsert the root-marker drag path already performs).
// NARROW DEFAULT: seed [root-6, root+5] (one octave centred on the bank root, clamped
// to [0,127]) so the new zone is immediately "authored" (narrow) and survives
// reconcileSingleCaptureZones without being treated as a Sample-face full-range zone.
std::string seed = !selectedId_.empty() ? selectedId_
: (!visible_.empty() ? visible_.front().id : std::string());
if (seed.empty()) return;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) {
selectedZone_ = i;
invalidate();
return;
}
}
// Look up the seed's root note from the browser list (absent root defaults to 60).
int seedRoot = 60;
for (const SampleChoice& sc : samples_) {
if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; }
}
const int lo = (std::max)(0, seedRoot - 6);
const int hi = (std::min)(127, seedRoot + 5);
PerformanceZone z;
z.sampleId = seed;
z.lowNote = lo;
z.highNote = hi;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
commitAndReload();
return;
}
Rect delR = zoneDeleteRect(addR);
if (selectedZone_ >= 0 && contains(delR, x, y)) {
map_.zones.erase(map_.zones.begin() + selectedZone_);
selectedZone_ = -1;
commitAndReload();
return;
}
// The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the
// selected zone's root.
const Rect stripArea = zonesStripArea(content);
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
const int lx = x - stripArea.x;
const int ly = y - stripArea.y;
std::vector<int> lows, highs;
lows.reserve(map_.zones.size());
highs.reserve(map_.zones.size());
for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); }
const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(),
highs.empty() ? nullptr : highs.data(),
static_cast<int>(map_.zones.size()), lx, ly);
if (hit.zoneIndex >= 0) {
selectedZone_ = hit.zoneIndex;
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(hit.zoneIndex)];
dragStartX_ = x;
dragStartLow_ = z.lowNote;
dragStartHigh_ = z.highNote;
dragStartMap_ = map_;
switch (hit.grab) {
case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break;
case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break;
case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break;
default: drag_ = DragKind::kNone; break;
}
invalidate();
return;
}
// A bare key-click inside the strip sets the selected zone's root override.
if (contains(stripArea, x, y) && selectedZone_ >= 0 &&
selectedZone_ < static_cast<int>(map_.zones.size())) {
const int note = keyAtPoint(sl, lx, ly);
if (note >= 0) {
map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
commitAndReload();
}
return;
}
// S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
// zone is selected. entryText_ starts empty (the user types the full value).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const Rect fields = noteEntryFieldsArea(content);
for (int f = 0; f < 3; ++f) {
if (contains(noteEntryFieldRect(fields, f), x, y)) {
entryField_ = f;
entryText_.clear();
invalidate();
return;
}
}
}
entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry
// The per-zone param surface (FB2): the knob deck + the mini curve-preview button — the
// SAME grammar and hit-test machinery as the Sample face. Only when a zone is selected
// (the Zone surface has no single-capture fallback — that lives on the Sample face).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
if (contains(zonesCurveButton(content), x, y)) {
curvePopupOpen_ = true;
invalidate();
return;
}
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
const Rect deckArea = zonesDeckArea(content);
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, deckArea.y,
deckArea.width);
const DeckHit hit = hitTestDeck(dl, x, y);
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
// Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete,
// final edit committed at once (the deck precedent). No per-instance ids reach
// here — VOICE/MASTER are not in the zone group set.
applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment);
commitAndReload();
return;
}
if (hit.kind == DeckHitKind::Knob) {
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the
// Sample deck's guard, mirrored.
const bool pitchEnvKnob =
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
if (pitchEnvKnob && !play.pitchEnv.enabled) return;
// GRAB-ANCHORED vertical drag (FA4): live-drag the map, commit on release.
drag_ = DragKind::kDeckKnob;
dragParamId_ = hit.id;
dragParamZone_ = selectedZone_;
dragStartMap_ = map_;
dragKnobStartValue_ = deckControlNorm(
hit.id, map_.zones[static_cast<std::size_t>(selectedZone_)]);
dragStartX_ = x;
dragStartY_ = y;
invalidate();
}
}
}
void ReaSamplerEditor::onMouseWheel(int delta) {
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive
// delta (wheel up) scrolls toward the top (smaller offset).
if (view_ != View::kBrowse) return;
const int rows = delta / 120;
if (rows == 0) return;
scrollOffset_ -= rows * kBrowserCardHeight;
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
invalidate();
}
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
// r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face
// or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search
// cannot hold focus under it).
if (curvePopupOpen_ && ch == 27) {
curvePopupOpen_ = false;
invalidate();
return;
}
// S12 numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes
// and commits via parseNoteEntry on Enter. Handled before the search box (a field, when
// focused, owns the keystrokes).
if (view_ == View::kZone && entryField_ >= 0) {
if (ch == 13) { // Enter: parse + commit
if (auto note = parseNoteEntry(entryText_)) {
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote);
else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote);
else z.rootOverride = *note;
commitAndReload();
}
}
entryField_ = -1;
entryText_.clear();
invalidate();
} else if (ch == 27) { // Escape cancels
entryField_ = -1;
entryText_.clear();
invalidate();
} else if (ch == 8) { // backspace
if (!entryText_.empty()) entryText_.pop_back();
invalidate();
} else if (ch >= 32 && ch < 127) {
entryText_.push_back(static_cast<char>(ch));
invalidate();
}
return;
}
// S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
// deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search).
if (view_ != View::kBrowse || !searchFocused_) return;
if (ch == 8) { // backspace
if (!searchQuery_.empty()) searchQuery_.pop_back();
} else if (ch == 27) { // escape clears + defocuses
searchQuery_.clear();
searchFocused_ = false;
} else if (ch >= 32 && ch < 127) {
searchQuery_.push_back(static_cast<char>(ch));
} else {
return; // ignore other control chars
}
scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list
rebuildVisible();
invalidate();
}
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
// S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact
// ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff
// decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a
// timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing
// at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs
// each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0.
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
dropHintTicks_ = 6;
#ifdef _WIN32
invalidate();
#endif
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -0,0 +1,586 @@
// editor_input_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE input + the drag-state
// machine (Q-W2v split of reasampler_editor.cpp, T4-11): the mouse-down dispatch (the
// Sample-face branch inline; Browse/Zone branches delegate to editor_input_browse_zone),
// the curve-popup/curve-box click machinery, the live drag resolution (onMouseMove — deck
// knobs, root marker, envelope nodes, curve nodes, wave markers, scroll thumb, zone
// edges), the release commit (onMouseUp), and the popup right-click delete. Windows-only
// (D5). All hit-test math is pure; this TU routes and mutates editor state only.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + thumbDragToOffset (scroll drag)
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet (r11)
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag (S-VIEW-3)
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
#include "core/instrument/ui/param_slider.h" // knobDragValue (FA4 grab-anchored drag)
#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap (S11)
#include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui;
using namespace reasampler::instrument::ui;
using namespace reasampler::instrument::map;
bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
// The r11 curve popup: while open the sheet is MODAL over its host face — the Sample home
// (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash
// click dismiss (outside only when no drag is in flight, per the spec); in-box clicks
// route to the shared curve machinery against popupZoneIndex(); anything else on the
// sheet is swallowed.
if (!curvePopupOpen_) return false;
const CurvePopupLayout pl = computeCurvePopup(w, h);
if (contains(pl.close, x, y)) {
curvePopupOpen_ = false;
invalidate();
return true;
}
if (contains(pl.curveBox, x, y)) {
const int zi = popupZoneIndex();
if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y);
return true;
}
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
curvePopupOpen_ = false;
invalidate();
}
return true;
}
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) {
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
int idx = z.velocityCurve.pointAtPixel(box, x, y);
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
selectedZone_ = zoneIndex;
commitAndReload();
}
return;
}
// Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight
// ADD (mirror of the other map-editing drags' dragStartMap_ contract).
dragStartMap_ = map_;
// Empty-space click inside the MAPPING BOX: add a control point at the cursor via the pure
// inverse map, then grab it — the click flows straight into a placing drag. Guard: the caller
// gates on contains(r, x, y) (the full border rect), but the 6+px inset ring — including the
// caption band — must not add a point; a click there would clamp to velocity 0/127 and
// produce an undeletable duplicate stacked on an endpoint. Clicks in the ring may still grab
// an existing node (pointAtPixel's pick radius legitimately extends into the ring), which is
// handled above; only the add path is box-gated here.
if (idx < 0) {
const bool inBox = (x >= box.left && x < box.left + box.width &&
y >= box.top && y < box.top + box.height);
if (inBox) {
const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y);
idx = static_cast<int>(z.velocityCurve.addPoint(p.velocity, p.amp));
}
}
if (idx < 0) return; // ring click with no node hit — nothing to grab
drag_ = DragKind::kCurveNode;
curvePointIndex_ = idx;
dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base
dragCurveRect_ = r;
dragCurveZone_ = zoneIndex;
dragStartX_ = x;
dragStartY_ = y;
selectedZone_ = zoneIndex;
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
}
// --- Input: the drag-state machine -------------------------------------------
void ReaSamplerEditor::onMouseDown(int x, int y) {
if (!processor_) return;
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
// ---- Browse modal (S-VIEW-5): the face branch lives in editor_input_browse_zone ----
if (view_ == View::kBrowse) {
mouseDownBrowse(w, h, x, y);
return;
}
// ---- Sample home (S-VIEW-2 / r11) ----
if (view_ == View::kSample) {
// r11 curve popup: while open the sheet is modal — it owns every left-click.
if (handlePopupMouseDown(w, h, x, y)) return;
const PerformanceZone probeZone = effectiveSampleZone();
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(probeZone.play);
const SampleBands bands =
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
if (contains(bands.navBrowse, x, y)) {
// Open the Browse modal; seed its pending pick from the loaded id so the current
// capture reads as pre-selected.
browsePendingId_ = selectedId_;
lastBrowseClickCard_ = -1;
view_ = View::kBrowse;
invalidate();
return;
}
if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; }
if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
// Preview-trigger button: fire the loaded capture at its root through the voice engine
// (momentary — note-on on press, note-off on release).
if (contains(cr.preview, x, y)) {
const int note = effectiveRoot();
if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_);
previewingNote_ = note;
processor_->previewNoteOn(note);
invalidate();
return;
}
// Radial preview-velocity knob (r11): GRAB-ANCHORED vertical drag — the grab itself
// never jumps the value (FA4); the delta from the grab point maps via knobDragValue.
if (contains(cr.velCell, x, y)) {
drag_ = DragKind::kDeckKnob;
dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param)
dragParamZone_ = -1;
dragKnobStartValue_ = previewVelocity01();
dragStartX_ = x;
dragStartY_ = y;
invalidate();
return;
}
// The mini curve-preview button: summon the popup editor.
if (contains(cr.curveBtn, x, y)) {
curvePopupOpen_ = true;
invalidate();
return;
}
// Channel toggle.
if (contains(chan.mono, x, y)) {
channelMode_ = ChannelMode::Mono;
processor_->setChannelMode(ChannelMode::Mono);
invalidate();
return;
}
if (contains(chan.stereo, x, y)) {
channelMode_ = ChannelMode::Stereo;
processor_->setChannelMode(ChannelMode::Stereo);
invalidate();
return;
}
// The knob deck (r11): toggles commit at once (a discrete, final edit — the slider
// precedent); knobs start a grab-anchored vertical drag. The deck band swallows its
// clicks (no fall-through to the hero/markers).
if (contains(bands.deck, x, y)) {
const DeckLayout dl = layoutDeck(deckDescs, bands.deck.x, bands.deck.y,
bands.deck.width);
const DeckHit hit = hitTestDeck(dl, x, y);
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
switch (static_cast<ParamControl>(hit.id)) {
case ParamControl::kVoiceMode: {
// Processor-side per-instance param: live setter (engine rebuild via
// the drain-slot swap — tails survive), local snapshot in step.
const VoiceMode m =
(hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly;
if (m != voiceMode_) {
voiceMode_ = m;
processor_->setVoiceMode(m);
}
invalidate();
break;
}
case ParamControl::kMonoTrigger: {
if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly
const MonoTrigger t =
(hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
if (t != monoTrigger_) {
monoTrigger_ = t;
processor_->setMonoTrigger(t);
}
invalidate();
break;
}
default: {
// Zone-param toggles (play mode / pitch engine / pitch-env enable):
// materialize the one-zone site, apply, commit.
const int zi = ensureSampleZone();
if (zi >= 0) {
applyZoneControl(zi, hit.id, 0.0, hit.segment);
selectedZone_ = zi;
commitAndReload();
}
break;
}
}
return;
}
if (hit.kind == DeckHitKind::Knob) {
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off.
const bool pitchEnvKnob =
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return;
if (hit.id == static_cast<int>(ParamControl::kVoiceCount) ||
hit.id == static_cast<int>(ParamControl::kMasterGain)) {
// Processor-side knobs: transient live writes, no map edit, no reload.
drag_ = DragKind::kDeckKnob;
dragParamId_ = hit.id;
dragParamZone_ = -1;
dragKnobStartValue_ = deckControlNorm(hit.id, probeZone);
} else {
// Zone-param knobs: live-drag the map, commit on release.
const int zi = ensureSampleZone();
if (zi < 0) return;
drag_ = DragKind::kDeckKnob;
dragParamId_ = hit.id;
dragParamZone_ = zi;
selectedZone_ = zi;
dragStartMap_ = map_;
dragKnobStartValue_ =
deckControlNorm(hit.id, map_.zones[static_cast<std::size_t>(zi)]);
}
dragStartX_ = x;
dragStartY_ = y;
invalidate();
}
return;
}
// Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
const Rect waveArea = bands.hero;
if (frames > 0) {
const double rate = liveSampleRate();
if (rate > 0.0) {
const PerformanceZone zone = effectiveSampleZone();
const std::int64_t startFrame = zone.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
const double totalSeconds = static_cast<double>(frames) / rate;
const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y);
if (nh.hit) {
drag_ = DragKind::kEnvNode;
envNode_ = nh.node;
dragStartX_ = x;
dragStartY_ = y;
dragStartEnv_ = env;
dragSampleFrames_ = frames;
dragStartFrame_ = startFrame;
dragStartMap_ = map_;
return; // node moves once the cursor drags
}
}
const SetupMarkers m = pickedMarkers(frames);
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y);
if (hit >= 0) {
drag_ = DragKind::kWaveMarker;
waveMarker_ = static_cast<WaveMarker>(hit);
dragStartX_ = x;
dragStartMarkers_ = m;
dragSampleFrames_ = frames;
dragStartMap_ = map_;
return;
}
}
// Fenced root strip: grab the root marker (remainder-width since r11).
if (cr.rootStrip.width > 0) {
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y);
if (note >= 0) {
drag_ = DragKind::kRootMarker;
dragStartX_ = x;
dragStartRoot_ = note;
dragStartMap_ = map_;
onMouseMove(x, y); // apply the click as the first delta==0 set
return;
}
}
return;
}
// ---- Zone surface (S-VIEW-8 / FB2): the face branch lives in editor_input_browse_zone ----
mouseDownZone(w, h, x, y);
}
void ReaSamplerEditor::onMouseMove(int x, int y) {
if (drag_ == DragKind::kNone) return;
dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn)
dragCurY_ = y;
RECT rc{};
GetClientRect(childHwnd_, &rc);
const int w = rc.right - rc.left;
const int h = rc.bottom - rc.top;
const int dx = x - dragStartX_;
if (drag_ == DragKind::kDeckKnob) {
// r11 radial knob: GRAB-ANCHORED vertical drag — knobDragValue maps the y delta from
// the value at grab (up = increase), so the value tracks relative motion and never
// jumps on grab (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP.
const int dy = y - dragStartY_;
applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy));
invalidate();
return;
}
// r11: the Sample bands derive from the deck height (mode-independent width math). Hoisted
// below the kDeckKnob early-return — that branch uses neither deckDescs nor bands.
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(effectiveSampleZone().play);
const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
if (drag_ == DragKind::kRootMarker) {
// The fenced root strip on the Sample cluster band. Setting the root materializes a
// full-keyboard zone carrying the override on the picked id (the D-B override vehicle) —
// upsert by id so a repeated drag edits the same zone rather than stacking duplicates.
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
const Rect stripArea = clusterRects(bands.cluster, chan.mono, kDeckKnobSize).rootStrip;
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
const int note = resolveDragNote(sl, dragStartRoot_, dx);
bool found = false;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
if (z.sampleId == selectedId_) {
z.rootOverride = note;
selectedZone_ = i;
found = true;
break;
}
}
if (!found) {
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
z.rootOverride = note;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
}
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
return;
}
if (drag_ == DragKind::kEnvNode) {
// S-VIEW-3: resolve the grabbed envelope node's new params from the pixel delta (through
// the pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the
// picked id's one-zone play params. The AmpEnvelope was snapshotted at grab (dragStartEnv_)
// so the delta is absolute. Materialize the zone if needed (mirror of the marker path).
const std::int64_t frames = dragSampleFrames_;
const double rate = liveSampleRate();
if (frames <= 0 || rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const int dy = y - dragStartY_;
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, bands.hero,
totalSeconds, envClampBounds(), dx, dy);
const int zi = ensureSampleZone();
if (zi >= 0) {
unpackEnvelope(edited, frames, dragStartFrame_,
map_.zones[static_cast<std::size_t>(zi)].play);
selectedZone_ = zi;
}
invalidate(); // live feedback; commit on WM_LBUTTONUP
return;
}
if (drag_ == DragKind::kCurveNode) {
// S-VIEW-10: resolve the grabbed control point from the pixel delta through the pure
// inverse map (box + neighbour-X + endpoint-pin clamps), against the grab-time curve +
// box (absolute delta — the mirror of the envelope-node drag). Live feedback only; the
// commit lands on WM_LBUTTONUP.
if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast<int>(map_.zones.size())) return;
if (curvePointIndex_ < 0) return;
const int dy = y - dragStartY_;
map_.zones[static_cast<std::size_t>(dragCurveZone_)].velocityCurve =
VelocityCurve::resolvePointDrag(dragStartCurve_,
static_cast<std::size_t>(curvePointIndex_),
curveBoxFromRect(dragCurveRect_), dx, dy);
invalidate();
return;
}
if (drag_ == DragKind::kWaveMarker) {
// S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap
// it against the decoded PCM, apply the inter-marker clamps, and write the override live.
const Rect waveArea = bands.hero;
const std::int64_t frames = dragSampleFrames_;
if (frames <= 0) return;
// Grabbed frame at grab time, from the snapshot (so the delta is measured from grab).
const int idx = static_cast<int>(waveMarker_);
const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
dragStartMarkers_.loopEnd};
std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx);
// Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware
// requirement). Pure over the cached mono frames — no host types, no file I/O.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
if (!pcm.empty()) {
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
newFrame);
}
// Build the edited marker set from the snapshot, moving only the grabbed marker, then
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
SetupMarkers m = dragStartMarkers_;
if (waveMarker_ == WaveMarker::kStart) {
m.start = newFrame;
} else if (waveMarker_ == WaveMarker::kLoopStart) {
m.loopStart = (std::min)(newFrame, m.loopEnd);
m.hasLoop = true;
} else { // kLoopEnd
m.loopEnd = (std::max)(newFrame, m.loopStart);
m.hasLoop = true;
}
if (m.start < 0) m.start = 0;
if (m.start > frames - 1) m.start = frames - 1;
// Upsert the override on the picked id (mirror of the root-marker path); commit lands on
// release, this is live feedback. Set selectedZone_ so the control panel stays visible
// after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty
// map hides controls after the first marker drag on the single-capture face).
selectedZone_ = upsertPickedOverride(m);
invalidate();
return;
}
if (drag_ == DragKind::kScrollThumb) {
// S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag
// only happens in the Browse modal (the sole card grid). The visible-card window recomputes
// at paint from scrollOffset_.
const int dyThumb = y - dragStartY_;
const BrowseModal bm = computeBrowseModal(w, h);
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
dragStartScrollOffset_, dyThumb);
invalidate();
return;
}
// Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached
// in the Zone surface where selectedZone_ is set + the strip lives under its content area.
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
const Rect stripArea = zonesStripArea(zoneContentArea(w, h));
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
if (drag_ == DragKind::kZoneLow) {
z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote);
} else if (drag_ == DragKind::kZoneHigh) {
z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote);
} else if (drag_ == DragKind::kZoneBody) {
// Move the whole span: apply the SAME delta to both edges so the span is preserved,
// clamping so neither edge escapes [0,127] (the span shifts, never shrinks).
const int newLow = resolveDragNote(sl, dragStartLow_, dx);
const int newHigh = resolveDragNote(sl, dragStartHigh_, dx);
const int span = dragStartHigh_ - dragStartLow_;
if (newLow < 0) { z.lowNote = 0; z.highNote = span; }
else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; }
else { z.lowNote = newLow; z.highNote = newHigh; }
}
invalidate();
}
void ReaSamplerEditor::onMouseUp(int x, int y) {
// Release a held preview note first (the preview button is a momentary key: note-off on up).
// This runs regardless of drag state — the preview press does not start a drag.
if (previewingNote_ >= 0) {
if (processor_) processor_->previewNoteOff(previewingNote_);
previewingNote_ = -1;
invalidate();
}
if (drag_ == DragKind::kNone) return;
const DragKind kind = drag_;
const int paramId = dragParamId_;
const int curveIdx = curvePointIndex_;
const int curveZone = dragCurveZone_;
const Rect curveRect = dragCurveRect_;
drag_ = DragKind::kNone;
dragParamId_ = -1;
dragParamZone_ = -1;
curvePointIndex_ = -1;
dragCurveZone_ = -1;
// A scrollbar drag is transient UI (no map change), and the processor-side knobs (the
// preview-velocity -2 sentinel, voice count, master gain) are per-instance settings that
// don't reload the instrument via the map path. Master gain is an atomic the audio thread
// reads directly. Voice count: the label/needle tracks live during the drag but the engine
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
const bool deckTransient =
kind == DragKind::kDeckKnob &&
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
paramId == static_cast<int>(ParamControl::kMasterGain));
if (kind == DragKind::kScrollThumb || deckTransient) {
// Commit the voice count now that the drag is complete (one rebuild per full drag).
if (deckTransient && processor_ &&
paramId == static_cast<int>(ParamControl::kVoiceCount))
processor_->setVoiceCount(voiceCount_);
invalidate();
return;
}
// S-VIEW-10 drag-off delete: releasing a curve-node drag well OUTSIDE the box removes the
// dragged point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain
// move — its amp keeps the last clamped drag value).
if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 &&
curveZone < static_cast<int>(map_.zones.size())) {
const bool off = x < curveRect.x - kCurveDragOffMargin ||
x > curveRect.right() + kCurveDragOffMargin ||
y < curveRect.y - kCurveDragOffMargin ||
y > curveRect.bottom() + kCurveDragOffMargin;
if (off) {
map_.zones[static_cast<std::size_t>(curveZone)].velocityCurve.deletePoint(
static_cast<std::size_t>(curveIdx));
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node on next paint
}
}
commitAndReload();
}
void ReaSamplerEditor::onMouseRDown(int x, int y) {
// r11 (issue 3c): right-click on a popup curve node deletes it — the PRIMARY delete
// affordance; Alt-click and drag-off remain as landed alternates. Commits immediately
// through the same path as Alt-click; deletePoint's endpoint guard makes an endpoint
// right-click a safe no-op. Right-clicks act ONLY while the popup is open — over the
// Sample face OR the Zone surface (FB2; nothing else in the editor consumes them) —
// and never during an in-flight left drag.
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
if (drag_ != DragKind::kNone) return;
RECT rc{};
GetClientRect(childHwnd_, &rc);
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
if (!contains(pl.curveBox, x, y)) return;
// Hit-test first (read-only, via popupZone) so a right-click that lands between nodes
// does not materialize an uncommitted zone in map_. Materialize only on an actual hit.
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
const int idx = popupZone().velocityCurve.pointAtPixel(box, x, y);
if (idx < 0) return;
const int zi = popupZoneIndex();
if (zi < 0) return;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zi)];
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
selectedZone_ = zi;
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
commitAndReload();
}
}
} // namespace reasampler::vst
#endif // _WIN32
+239
View File
@@ -0,0 +1,239 @@
// editor_internal.h — INTERNAL shared helpers for the ReaSamplerEditor TU family
// (Q-W2v: the eight face-axis TUs split out of the former reasampler_editor.cpp).
// Included ONLY by the editor's own shell TUs (editor_session / editor_controls /
// editor_paint_* / editor_input_* / editor_platform) — never a public seam. Holds the
// former god-TU's anonymous-namespace helpers that more than one split TU needs: the
// Rect<->kit adapters, the small draw primitives (knob face / spectral strip / root
// marker / title band), the label helpers, the deck group ids, and the velocity-curve
// box derivation. All inline; behavior-identical to the pre-split definitions.
#pragma once
#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve::Box (curveBoxFromRect)
#include "core/instrument/map/sample_map.h" // SampleChoice / SampleRefs (sampleLabel)
#include "core/instrument/ui/editor_geometry.h" // Rect (the shared sub-rect type)
#ifdef _WIN32
#include "wdltypes.h"
#include "lice/lice.h"
#include "core/audio/peaks.h" // Envelope (drawEnvelope)
#include "core/instrument/ui/capture_browser.h" // BrowserLayout / cardThumbnailRect (thumbBins)
#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace, FA4)
#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / isNaturalKey (spectral strip)
#include "core/ui/component_geometry.h" // KitBox / waveformColumnCount
#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor
#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text/drawWaveform/toLice
#endif
namespace reasampler::vst {
// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck
// order. Shared by the deck-desc builders (editor_controls) and the deck painter.
enum DeckGroup {
kGroupAmpEnv = 0,
kGroupPitch,
kGroupPitchEnv,
kGroupVoice,
kGroupMaster,
};
// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the
// curve in the POPUP (curve_popup), each summoned from its own mini preview button. The
// INSET keeps node handles + the pick radius inside the border so an endpoint at amp 0/1
// stays grabbable — the ONE curveBoxFromRect grammar the popup derives its mapping box
// through. Drag-off: release beyond box+margin deletes the dragged node.
inline constexpr int kVelCurveInset = 14;
inline constexpr int kCurveDragOffMargin = 24;
// The pure-module mapping Box for a drawn curve rect: inset from the border so node
// handles and the pick radius stay inside the box. Every consumer (paint, hit-test, add,
// drag) derives the Box through this ONE formula, so drawn nodes and grabs never drift.
inline instrument::engine::VelocityCurve::Box curveBoxFromRect(
const instrument::ui::Rect& r) {
return instrument::engine::VelocityCurve::Box{
r.x + kVelCurveInset, r.y + kVelCurveInset,
(std::max)(0, r.width - 2 * kVelCurveInset),
(std::max)(0, r.height - 2 * kVelCurveInset)};
}
// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the
// common DAW convention REAPER uses).
inline std::string noteLabel(int note) {
static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F",
"F#", "G", "G#", "A", "A#", "B"};
if (note < 0) note = 0;
if (note > 127) note = 127;
const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4
return std::string(kNames[note % 12]) + std::to_string(octave);
}
// A display name for a bank sample id: the snapshotted bank list first, then the
// instance-OWNED ref's displayName (pS — the label survives with the extension absent /
// bank unreadable). "?" only when neither source knows the id.
inline std::string sampleLabel(const std::vector<instrument::map::SampleChoice>& samples,
const instrument::map::SampleRefs& refs,
const std::string& id) {
for (const instrument::map::SampleChoice& c : samples) {
if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
}
for (const instrument::map::SampleRefEntry& e : refs) {
if (e.sampleId == id && !e.displayName.empty()) return e.displayName;
}
return "?";
}
#ifdef _WIN32
// --- Rect <-> kit adapters (Phase L, L3) -------------------------------------
//
// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against
// `KitBox` (component_geometry). This is the single boundary that bridges them so every
// draw routes through the L1 kit (theme roles + draw_kit).
inline ui::KitBox toKitBox(const instrument::ui::Rect& r) {
return ui::KitBox{r.x, r.y, r.width, r.height};
}
// Kit text in a palette ROLE (the common case). Left/Right/Center via Align.
inline void kitText(LICE_IBitmap* bmp, const instrument::ui::Rect& r, const char* s,
Font font, ui::Role role, Align align = Align::Left) {
text(bmp, toKitBox(r), s, font, role, align);
}
inline void kitTextCentered(LICE_IBitmap* bmp, const instrument::ui::Rect& r,
const char* s, Font font, ui::Role role) {
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).
inline void drawEnvelope(LICE_IBitmap* bmp, const instrument::ui::Rect& r,
const audio::Envelope& env) {
drawWaveform(bmp, toKitBox(r), env);
}
// The bin count a card's thumbnail is computed at: one bin per drawn pixel column — the
// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins.
// thumbnailFor clamps the request to the decoded frame count.
inline int thumbBins(const instrument::ui::BrowserLayout& layout) {
return (std::max)(1, kWaveformOversample *
ui::waveformColumnCount(toKitBox(
instrument::ui::cardThumbnailRect(layout, 0))));
}
// Draw the title band with the live readout. Shared by the Sample face (nav visible) —
// Browse/Zone draw their own back button in place of the nav.
inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title,
const std::string& readout) {
fillSurface(bmp, toKitBox(title), ui::Role::BgPanel, ui::InteractionState::Rest);
instrument::ui::Rect titleText =
instrument::ui::Rect::ltrb(title.x + 8, title.y, title.right() - 8, title.bottom());
kitText(bmp, titleText, readout.c_str(), Font::Title, ui::Role::TextPrimary);
}
// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle
// map; this turns it into LICE calls through the kit's palette roles. LICE's arc
// convention matches param_slider's (angle 0 = 12 o'clock, positive clockwise) — but LICE
// takes RADIANS, and drawing the 7->5 o'clock sweep THROUGH the top needs a continuous
// angle span, so the degrees convert as (deg - 360) * pi/180, mapping 210..510 onto
// -150..+150 degrees. One conversion, both arcs.
inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect,
double value01, ui::InteractionState st) {
using instrument::ui::KnobArc;
using instrument::ui::KnobGeometry;
using instrument::ui::KnobPoint;
const KnobGeometry kg = instrument::ui::computeKnob(knobRect);
if (kg.radius <= 1.0) return;
constexpr double kDegToRad = 3.14159265358979323846 / 180.0;
const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep
const float cx = static_cast<float>(kg.centerX);
const float cy = static_cast<float>(kg.centerY);
const float rOuter = static_cast<float>(kg.radius) - 0.5f;
const bool disabled = (st == ui::InteractionState::Disabled);
const bool hot = (st == ui::InteractionState::Dragging || st == ui::InteractionState::Hover);
// Face: a filled circle in the cell surface color under the interaction state.
LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(ui::roleColorState(ui::Role::BgCell, st)),
1.0f, 0, true);
// Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare).
const float a0 = static_cast<float>((arc.startDeg - 360.0) * kDegToRad);
const float a1 = static_cast<float>(
(arc.startDeg + instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(ui::roleColor(ui::Role::LineHairline)), 1.0f, 0,
true);
// Value arc: start -> the value's angle, in the live accent (hot while under the pointer /
// dragging, dim when disabled).
const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01);
if (v > 0.0) {
const float av = static_cast<float>(
(arc.startDeg + v * instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
const ui::Role valueRole = disabled ? ui::Role::TextDim
: (hot ? ui::Role::AccentHot : ui::Role::AccentPrimary);
LICE_Arc(bmp, cx, cy, rOuter, a0, av, toLice(ui::roleColor(valueRole)), 1.0f, 0, true);
}
// Needle: from ~35% radius out to the rim at the value's angle.
const KnobPoint tip = instrument::ui::knobNeedlePoint(kg, arc, v);
const float ix = cx + static_cast<float>((tip.x - kg.centerX) * 0.35);
const float iy = cy + static_cast<float>((tip.y - kg.centerY) * 0.35);
const ui::Role needleRole = disabled ? ui::Role::TextDim : ui::Role::TextPrimary;
LICE_Line(bmp, static_cast<int>(ix + 0.5f), static_cast<int>(iy + 0.5f),
static_cast<int>(tip.x + 0.5f), static_cast<int>(tip.y + 0.5f),
toLice(ui::roleColor(needleRole)), 1.0f, 0, true);
}
// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature
// surface. Fills each MIDI key column with its spectral hue, then draws faint per-octave
// hairline ticks. Shared by the setup face + the Zones strip so both read as the same
// spectrum. S-VIEW-7: accidentals get a dark bg/base wash over the hue (an OVERLAY, not
// a keyboard shape) so pitch position reads as a keyboard at a glance.
inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) {
using instrument::ui::StripLayout;
if (stripArea.width <= 0 || stripArea.height <= 0) return;
const StripLayout sl = instrument::ui::layoutStrip(stripArea.width, stripArea.height);
const int sx = stripArea.x;
const int sy = stripArea.y;
const int h = stripArea.height;
const LICE_pixel darkKey = toLice(ui::roleColor(ui::Role::BgBase));
for (int n = 0; n <= 127; ++n) {
const instrument::ui::Rect k = instrument::ui::keyRect(sl, n);
const int x0 = k.x + sx;
const int x1 =
(n < 127) ? instrument::ui::keyRect(sl, n + 1).x + sx : stripArea.right();
const int cw = (std::max)(1, x1 - x0);
const ui::KitColor hue = ui::spectralColor(static_cast<double>(n) / 127.0);
LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0);
if (!instrument::ui::isNaturalKey(n)) {
LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 0.55f, 0);
}
}
// Faint per-octave key ticks (hairline role) for orientation.
const LICE_pixel tick = toLice(ui::roleColor(ui::Role::LineHairline));
for (int n = 0; n <= 127; n += 12) {
const instrument::ui::Rect k = instrument::ui::keyRect(sl, n);
LICE_Line(bmp, k.x + sx, sy, k.x + sx, sy + h, tick, 1.0f, 0, false);
}
}
// Draw the single-capture root marker on the strip: an accent-primary bar with a soft
// STATIC glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark.
inline void drawRootMarker(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea,
const instrument::ui::StripLayout& sl, int root) {
const int sx = stripArea.x;
const int sy = stripArea.y;
const int h = stripArea.height;
const instrument::ui::Rect marker = instrument::ui::rootMarkerRect(sl, root);
const int mw = (std::max)(2, marker.width);
const LICE_pixel accent = toLice(ui::roleColor(ui::Role::AccentPrimary));
const LICE_pixel glow = toLice(ui::roleColor(ui::Role::AccentHot));
// Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse).
LICE_FillRect(bmp, marker.x + sx - 3, sy, mw + 6, h, glow, 0.30f, 0);
LICE_FillRect(bmp, marker.x + sx, sy, mw, h, accent, 1.0f, 0);
}
#endif // _WIN32
} // namespace reasampler::vst
@@ -0,0 +1,278 @@
// editor_paint_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE
// painting (Q-W2v split of reasampler_editor.cpp, T4-11): the full-window select-then-
// confirm picker (S-VIEW-5 — wash, search box, filter tabs, card grid, scrollbar,
// footer) and the Zone keymap surface (S-VIEW-8/FB2 — add/delete, the spectral zones
// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only
// (D5). Shares the Sample face's painters (title band / empty state / deck / curve
// button / popup) via the class + editor_internal.h.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12)
#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout (FB2)
#include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + labels
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary
using namespace reasampler::instrument::ui; // browser/strip/deck/zone-surface geometry
using namespace reasampler::instrument::map; // SampleChoice / BankChoice / SampleRefs
void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
// A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying
// Sample face with a bg/base wash, then draw the picker opaque on top.
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0);
const BrowseModal bm = computeBrowseModal(w, h);
// Title band + Back button (returns to Sample, discarding any pending pick).
drawTitleBand(bmp, bm.title, "Browse - pick a capture");
{
const KitButtonBox box{toKitBox(bm.back)};
const InteractionState st =
isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "Back", st, /*warn=*/false);
}
// Search box (type-to-filter). A focused box lifts to Focus + a ring; else Rest/Hover.
const Rect searchAbs = bm.search;
const InteractionState searchState =
searchFocused_ ? InteractionState::Focus
: (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState);
if (searchFocused_) {
LICE_DrawRect(bmp, searchAbs.x, searchAbs.y, searchAbs.width - 1,
searchAbs.height - 1, toLice(roleColor(Role::TextPrimary)), 1.0f, 0);
}
{
std::string sb = searchQuery_.empty()
? std::string("Search captures...")
: ("Search: " + searchQuery_ + (searchFocused_ ? "_" : ""));
Rect sbText = Rect::ltrb(searchAbs.x + 6, searchAbs.y, searchAbs.right() - 6, searchAbs.bottom());
kitText(bmp, sbText, sb.c_str(), Font::Label,
searchQuery_.empty() ? Role::TextDim : Role::TextPrimary);
}
// Tabs + card grid, laid out over the content sub-area by the pure module (origin-offset).
const Rect browserArea = bm.content;
const BrowserLayout bl = layoutBrowser(browserArea.width, browserArea.height);
const int ox = browserArea.x;
const int oy = browserArea.y;
scrollOffset_ = clampScrollOffset(bl, static_cast<int>(visible_.size()), scrollOffset_);
const int tabCount = static_cast<int>(banks_.size()) + 1;
for (int i = 0; i < tabCount; ++i) {
Rect t = filterTabRect(bl, tabCount, i);
t = Rect::ltrb(t.x + ox, t.y + oy, t.right() + ox, t.bottom() + oy);
const std::string label = (i == 0) ? "All" : banks_[static_cast<std::size_t>(i - 1)].displayName;
const bool active = (i == 0) ? activeFilterBankId_.empty()
: (banks_[static_cast<std::size_t>(i - 1)].id == activeFilterBankId_);
const InteractionState state =
active ? InteractionState::Active
: (isHovered(HoverKind::kFilterTab, i) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(bmp, toKitBox(t), Role::BgCell, state);
kitTextCentered(bmp, t, label.c_str(), Font::Label,
active ? Role::BgBase : Role::TextPrimary);
}
// Cards (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_)
// is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border.
const int bins = thumbBins(bl);
const int cardCount = static_cast<int>(visible_.size());
const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_);
for (int i = vr.first; i < vr.last; ++i) {
Rect content = cardContentRect(bl, i);
Rect thumb = cardThumbnailRect(bl, i);
Rect labelR = cardLabelRect(bl, i);
content = Rect::ltrb(content.x + ox, content.y + oy - scrollOffset_,
content.right() + ox, content.bottom() + oy - scrollOffset_);
thumb = Rect::ltrb(thumb.x + ox, thumb.y + oy - scrollOffset_,
thumb.right() + ox, thumb.bottom() + oy - scrollOffset_);
labelR = Rect::ltrb(labelR.x + ox, labelR.y + oy - scrollOffset_,
labelR.right() + ox, labelR.bottom() + oy - scrollOffset_);
const SampleChoice& s = visible_[static_cast<std::size_t>(i)];
const bool pending = (s.id == browsePendingId_);
const bool loaded = (s.id == selectedId_);
const InteractionState cardState =
isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest;
fillSurface(bmp, toKitBox(content), Role::BgCell, cardState);
const KitColor cardBorder = pending ? roleColor(Role::AccentPrimary)
: (loaded ? roleColor(Role::AccentTertiary)
: roleColor(Role::LineHairline));
LICE_DrawRect(bmp, content.x, content.y, content.width - 1, content.height - 1,
toLice(cardBorder), 1.0f, 0);
drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins));
std::string caption = s.displayName.empty() ? s.id : s.displayName;
Rect nameR = Rect::ltrb(labelR.x + 3, labelR.y, labelR.right() - 3, labelR.y + labelR.height / 2);
Rect badgeR = Rect::ltrb(labelR.x + 3, nameR.bottom(), labelR.right() - 3, labelR.bottom());
kitText(bmp, nameR, caption.c_str(), Font::Label, Role::TextPrimary);
std::string badge;
if (s.rootNote) badge = "root " + noteLabel(*s.rootNote);
else if (s.key) badge = *s.key;
else badge = "root -";
kitText(bmp, badgeR, badge.c_str(), Font::Micro, Role::TextDim);
}
// Scrollbar thumb.
{
const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_);
if (thumb.height > 0) {
const bool dragging = (drag_ == DragKind::kScrollThumb);
const KitColor tc = roleColor(dragging ? Role::AccentHot : Role::AccentPrimary);
LICE_FillRect(bmp, thumb.x + ox, thumb.y + oy, thumb.width, thumb.height,
toLice(tc), 0.8f, 0);
}
}
if (visible_.empty()) paintEmptyState(bmp, browserArea);
// Footer: Cancel (discard, return to Sample) + Load (commit the pending pick). Load is inert
// (no accent) until a card is picked. Draw a footer strip so the buttons read as a modal bar.
Rect footer = Rect::ltrb(0, bm.content.bottom(), w, h);
fillSurface(bmp, toKitBox(footer), Role::BgPanel, InteractionState::Rest);
{
const KitButtonBox box{toKitBox(bm.cancel)};
const InteractionState st =
isHovered(HoverKind::kBrowseCancel, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "Cancel", st, /*warn=*/false);
}
{
const KitButtonBox box{toKitBox(bm.confirm)};
const bool armed = !browsePendingId_.empty();
const InteractionState st = armed
? (isHovered(HoverKind::kBrowseConfirm, -1) ? InteractionState::Hover : InteractionState::Active)
: InteractionState::Rest;
drawButton(bmp, box, "Load", st, /*warn=*/false);
}
}
void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
// Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns
// to the Sample home on close.
const Rect title = Rect::ltrb(0, 0, w, (std::min)(kTitleHeight, h));
drawTitleBand(bmp, title, "Zone - keyboard map");
{
const Rect back = zoneBackRect(w, h);
const KitButtonBox box{toKitBox(back)};
const InteractionState st =
isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "Back", st, /*warn=*/false);
}
const Rect content = zoneContentArea(w, h);
const int pad = 8;
// A single "+ Add Zone" affordance at the top of the content, then the keyboard strip
// with one bar per zone. Delete is a small × on the selected zone (keystroke also).
Rect addR = zoneAddRect(content);
{
const KitButtonBox box{toKitBox(addR)};
const InteractionState state =
isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false);
}
Rect delR = zoneDeleteRect(addR);
if (selectedZone_ >= 0) {
const KitButtonBox box{toKitBox(delR)};
const InteractionState state =
isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest;
// Deleting a zone is not a byte-destroying act (no file removed — the bank is
// read-only here), so it is a normal button, not `warn`.
drawButton(bmp, box, "Delete", state, /*warn=*/false);
}
// The zones strip — the same PASTEL SPECTRAL surface as the Sample face, with one bar per
// zone over the spectrum. The SELECTED zone lifts to accent-primary + a static glow ("which
// zone is live"); the rest take the categorical secondary hue at low alpha.
const Rect stripArea = zonesStripArea(content);
drawSpectralStrip(bmp, stripArea);
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
const int sx = stripArea.x;
const int sy = stripArea.y;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
Rect bar = zoneBarRect(sl, z.lowNote, z.highNote);
const int bw = (std::max)(2, bar.width);
const bool sel = (i == selectedZone_);
if (sel) {
// Static glow halo behind the live zone, then the crisp accent-primary bar.
LICE_FillRect(bmp, bar.x + sx - 2, sy, bw + 4, stripArea.height,
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
} else {
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
toLice(roleColor(Role::AccentSecondary)), 0.55f, 0);
}
}
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
// entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it
// (entryField_) and typed text commits via parseNoteEntry on Enter.
const int legendTop = stripArea.bottom() + 8;
Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18);
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
kitText(bmp, Rect::ltrb(infoR.x, infoR.y, infoR.x + 120, infoR.bottom()),
sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{},
z.sampleId)
.c_str(),
Font::Label, Role::TextPrimary);
// Three fields laid out left-to-right after the sample label. A focused field lifts to
// the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
const Rect fields = noteEntryFieldsArea(content);
const char* names[3] = {"Low", "High", "Root"};
const std::string vals[3] = {
noteLabel(z.lowNote), noteLabel(z.highNote),
z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")};
for (int f = 0; f < 3; ++f) {
const Rect fr = noteEntryFieldRect(fields, f);
const bool editing = (entryField_ == f);
fillSurface(bmp, toKitBox(fr), Role::BgCell,
editing ? InteractionState::Focus : InteractionState::Rest);
const KitColor border =
editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline);
LICE_DrawRect(bmp, fr.x, fr.y, fr.width - 1, fr.height - 1,
toLice(border), 1.0f, 0);
std::string cap = std::string(names[f]) + ": " +
(editing ? (entryText_ + "_") : vals[f]);
kitText(bmp, Rect::ltrb(fr.x + 4, fr.y, fr.right() - 2, fr.bottom()), cap.c_str(),
Font::ValueMono, Role::TextPrimary);
}
} else if (map_.zones.empty()) {
kitText(bmp, infoR,
"No zones. Add Zone maps the picked capture across the keyboard.",
Font::Label, Role::TextDim);
}
// The per-zone parameter surface for the selected zone. FB2 (R11-F2): the SAME knob deck +
// curve-preview-button/popup grammar as the Sample face — one control language over the one
// storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. Only
// the per-zone groups render here; VOICE/MASTER are per-instance (ComponentState) and live
// on the Sample deck only.
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play));
paintCurveButton(bmp, zonesCurveButton(content), z);
}
// The curve popup (FB2): a centered sheet over the whole Zone surface, drawn LAST —
// the same modal grammar as the Sample face.
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -0,0 +1,518 @@
// editor_paint_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE painting (Q-W2v split of
// reasampler_editor.cpp, T4-11): the WM_PAINT dispatch, the r11 Sample home face (title
// band + elastic hero waveform + root/preview cluster + bottom-anchored knob deck), the
// S-VIEW-3 envelope overlay, the velocity-curve editor + mini preview button + popup
// sheet (shared painters the Zone surface reuses, FB2), and the empty state. Windows-only
// (D5); draws through the L1 kit by palette role. All layout math is pure
// (editor_geometry / knob_deck / curve_popup) — this TU only draws.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
#include "core/audio/peaks.h" // computeEnvelope (hero waveform binning)
#include "core/instrument/ui/curve_popup.h" // r11 centered curve-popup sheet geometry (FB1)
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
#include "core/instrument/ui/waveform_view.h" // frameToX (S11 markers)
#include "core/version/app_version.h" // vstPluginName (channel-derived title band, S18)
#include "shell/instrument/editor_internal.h" // kit adapters + knob face/spectral strip/root marker
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …)
using namespace reasampler::instrument::ui; // pure geometry (bands / cluster / deck / popup / strip)
using namespace reasampler::instrument::map; // SampleRefs / findRef (title readout fallback)
using audio::computeEnvelope;
namespace {
// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette: start = teal
// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple.
constexpr Role kRoleStartMarker = Role::AccentSecondary;
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
} // namespace
void ReaSamplerEditor::paint(HDC hdc) {
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
if (w <= 0 || h <= 0) return;
LICE_SysBitmap bmp(w, h);
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
// S-VIEW-1 three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn
// OVER Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the
// modal reads as a sheet layered over the home face (the "picker over the document" grammar).
if (view_ == View::kZone) {
paintZone(&bmp, w, h);
} else {
paintSample(&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.
// It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn
// LAST so it overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
if (dropHintTicks_ > 0) {
const int bannerTop = (std::min)(kTitleHeight, h);
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH);
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
// hue with a dark label so it reads as "attention, not action".
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
kitTextCentered(&bmp, banner,
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
Font::Label, Role::BgBase);
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
// r11: the deck height comes from the pure knob_deck wrap (mode-independent — the AMP
// ENVELOPE group reserves its 5-cell Gate width, so Gate<->Trigger never changes it).
const PerformanceZone deckZone = effectiveSampleZone();
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(deckZone.play);
const SampleBands bands =
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
// Title: product name + live readout. Standard B palette — the beta channel gets NO distinct
// accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable
// signal.
std::string title = version::vstPluginName(); // channel-derived (S18)
if (processor_ && processor_->bridge().isConnected()) {
// The instance's OWN loaded state outranks bank availability (pS: the bank is a
// browser source, not the instrument's identity) — a self-contained instance names
// its sound (refs displayName fallback) even when the bank snapshot is empty.
if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
else if (!selectedId_.empty())
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
else if (samples_.empty()) title += " [bank empty]";
else title += " [pick a capture]";
} else {
title += " [host: no bridge]";
}
drawTitleBand(bmp, bands.title, title);
// Browse + Zone nav buttons (right of the title). Browse is the picker; Zone opens the keymap
// surface. When nothing is loaded, Browse is the empty state's dominant call-to-action — draw
// it Active (accent-primary) so it reads as "start here".
const bool empty = selectedId_.empty() && map_.zones.empty();
{
const KitButtonBox box{toKitBox(bands.navBrowse)};
const InteractionState st = empty ? InteractionState::Active
: (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover : InteractionState::Rest);
drawButton(bmp, box, "Browse", st, /*warn=*/false);
}
{
const KitButtonBox box{toKitBox(bands.navZone)};
const InteractionState st =
isHovered(HoverKind::kNavZone, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "Zone", st, /*warn=*/false);
}
// Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing
// at Browse (which is lit above). No hero waveform / controls to draw.
if (empty) {
Rect body = Rect::ltrb(bands.hero.x, bands.hero.y, bands.hero.right(), bands.deck.bottom());
paintEmptyState(bmp, body);
return;
}
// Resolve the effective single-capture zone: the picked id's one-zone override when present,
// else the product-default play params (S15-F2 — the single capture is a one-zone map). This
// is the ONE storage site both Sample and Zone edit.
const PerformanceZone& zone = deckZone;
// --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay -----------
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
const Rect waveArea = bands.hero;
fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest);
if (frames > 0 && waveArea.width > 0) {
// FA3 gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
// multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
// partition — extra bins produce no visible change. Clamped to frame count below.
const std::int64_t wantBins =
static_cast<std::int64_t>((std::max)(1, waveformColumnCount(toKitBox(waveArea)))) *
kWaveformOversample;
const std::size_t bins =
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins);
drawEnvelope(bmp, waveArea, env);
const SetupMarkers m = pickedMarkers(frames);
if (m.hasLoop && m.loopEnd > m.loopStart) {
const int lx = frameToX(waveArea, frames, m.loopStart);
const int rx = frameToX(waveArea, frames, m.loopEnd);
if (rx > lx) {
LICE_FillRect(bmp, lx, waveArea.y, rx - lx, waveArea.height,
toLice(roleColor(kRoleLoopMarker)), 0.20f, 0);
}
}
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
for (int i = 0; i < 3; ++i) {
const int mx = frameToX(waveArea, frames, markerFrames[i]);
const bool loopMarker = (i != 0);
const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
LICE_FillRect(bmp, mx - 1, waveArea.y, 2, waveArea.height,
toLice(roleColor(markerRoles[i])), alpha, 0);
}
// S-VIEW-3: trace the amp-envelope overlay + its draggable node handles over the hero.
paintEnvelopeOverlay(bmp, waveArea, zone, frames);
} else {
kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
}
// --- Root + preview cluster (r11: remainder-width root strip, preview button, radial
// velocity knob, mini curve-preview button, channel toggle) -----------------------------
fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest);
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
int root = effectiveRoot();
if (cr.rootStrip.width > 0) {
drawSpectralStrip(bmp, cr.rootStrip);
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
drawRootMarker(bmp, cr.rootStrip, sl, root);
}
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
{
const KitButtonBox box{toKitBox(cr.preview)};
const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest);
drawButton(bmp, box, "Preview", st, /*warn=*/false);
}
// Preview velocity: a RADIAL knob cell (r11 — the deck cell grammar), bound to the same
// persisted previewVelocity seam. Label swaps to the live value during hover/drag.
{
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2);
const bool hov = isHovered(HoverKind::kVelKnob, -1);
const InteractionState st = dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover
: InteractionState::Rest);
drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st);
if (dragging || hov) {
char buf[8];
snprintf(buf, sizeof(buf), "%d",
static_cast<int>(previewVelocity01() * 127.0 + 0.5));
kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim);
} else {
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
}
}
// The mini curve-preview button (r11): opens the popup editor. Shared painter with the
// Zone panel's button (FB2 — one grammar on both surfaces).
paintCurveButton(bmp, cr.curveBtn, zone);
// Mono | Stereo output-mode toggle.
{
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
const InteractionState monoState = !isStereo ? InteractionState::Active
: (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover : InteractionState::Rest);
const InteractionState stereoState = isStereo ? InteractionState::Active
: (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover : InteractionState::Rest);
fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState);
fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState);
kitTextCentered(bmp, chan.mono, "Mono", 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) -------------------
paintKnobDeck(bmp, bands.deck, zone, deckDescs);
// --- The curve popup (r11): a centered sheet over the whole Sample face, drawn LAST ----
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
}
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea,
const PerformanceZone& zone, std::int64_t frames) {
if (frames <= 0 || waveArea.width <= 0 || waveArea.height <= 0) return;
const double rate = liveSampleRate();
if (rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const std::int64_t startFrame = zone.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
// curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right).
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
for (std::size_t i = 1; i < poly.size(); ++i) {
const int x0 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i - 1].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);
}
// Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw-
// only). Lit accent-hot when this node is the grabbed one. FA2 guarantees every vertex is
// in-bounds (the pre-FA2 right-edge clip is dead and removed — edge nodes like ReleaseEnd
// at area.right()-1 MUST get handles); the handle SQUARE is additionally clamped inside the
// hero rect so a 6px box on an edge node never overhangs into the neighbouring bands.
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
for (const EnvVertex& v : poly) {
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
const int r = 3;
const int hx = (std::max)(waveArea.x + r, (std::min)(waveArea.right() - 1 - r, v.x));
const int hy = (std::max)(waveArea.y + r, (std::min)(waveArea.bottom() - 1 - r, v.y));
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0);
}
}
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
const PerformanceZone& zone) {
if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect)
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (FB2: the
// popup is the only host).
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return;
const VelocityCurve& curve = zone.velocityCurve;
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
// secondary accent (the same grammar as the envelope trace over the hero). The x -> velocity
// and amp -> y mappings both go through the pure module so the trace, the node handles, and
// the hit-test all share one coordinate system.
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
int prevX = 0, prevY = 0;
for (int px = 0; px <= box.width; ++px) {
const int cx = box.left + px;
const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity;
const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y;
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
prevX = cx;
prevY = cy;
}
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
// to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor
// has passed kCurveDragOffMargin outside the box — release will delete the node).
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
const LICE_pixel handleWarn = toLice(roleColor(Role::Warn));
// Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin?
const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.x == r.x &&
dragCurveRect_.y == r.y) &&
(dragCurX_ < r.x - kCurveDragOffMargin ||
dragCurX_ > r.right() + kCurveDragOffMargin ||
dragCurY_ < r.y - kCurveDragOffMargin ||
dragCurY_ > r.bottom() + kCurveDragOffMargin);
for (std::size_t i = 0; i < curve.points().size(); ++i) {
const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]);
const bool grabbed = (drag_ == DragKind::kCurveNode &&
curvePointIndex_ == static_cast<int>(i));
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
// A grabbed node in drag-off territory draws warn to signal "release will delete."
const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn
: (hot ? handleHot : handle);
const int nr = 3;
LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0);
}
}
void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
const PerformanceZone& zone,
const std::vector<DeckGroupDesc>& descs) {
if (deckArea.width <= 0 || deckArea.height <= 0) return;
const DeckLayout dl = layoutDeck(descs, deckArea.x, deckArea.y, deckArea.width);
const ZonePlaySeconds& play = zone.play;
const bool isMono = (voiceMode_ == VoiceMode::Mono);
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1,
bool seg1Active, bool disabled) {
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
const InteractionState st0 =
disabled ? InteractionState::Disabled
: (!seg1Active ? InteractionState::Active
: (hov ? InteractionState::Hover : InteractionState::Rest));
const InteractionState st1 =
disabled ? InteractionState::Disabled
: (seg1Active ? InteractionState::Active
: (hov ? InteractionState::Hover : InteractionState::Rest));
fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0);
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
disabled ? Role::TextDim
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
disabled ? Role::TextDim
: (seg1Active ? Role::BgBase : Role::TextPrimary));
};
// The knob's short name label (swapped for the live value during hover/drag — r11: no
// third line, no permanent value clutter).
const auto knobName = [](ParamControl c) -> const char* {
switch (c) {
case ParamControl::kAttack: return "Attack";
case ParamControl::kHold: return "Hold";
case ParamControl::kDecay: return "Decay";
case ParamControl::kSustain: return "Sustain";
case ParamControl::kRelease: return "Release";
case ParamControl::kTrigFadeIn: return "Fade In";
case ParamControl::kTrigLength: return "Len %";
case ParamControl::kTrigFadeOut: return "Fade Out";
case ParamControl::kKeyTrack: return "Key Trk";
case ParamControl::kPitchEnvAttack: return "P.Att";
case ParamControl::kPitchEnvDecay: return "P.Dec";
case ParamControl::kPitchEnvDepth: return "P.Depth";
case ParamControl::kVoiceCount: return "Voices";
case ParamControl::kMasterGain: return "Gain";
default: return "";
}
};
for (const DeckGroupLayout& g : dl.groups) {
// The fence: a bg/panel box with a hairline border, caption micro-caps left.
fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest);
LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1,
hairline, 1.0f, 0);
const char* caption = "";
switch (g.id) {
case kGroupAmpEnv: caption = "AMP ENVELOPE"; break;
case kGroupPitch: caption = "PITCH"; break;
case kGroupPitchEnv: caption = "PITCH ENV"; break;
case kGroupVoice: caption = "VOICE"; break;
case kGroupMaster: caption = "MASTER"; break;
default: break;
}
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
// The compact caption toggle (r11: right-anchored IN the caption row, never full-width).
if (g.captionToggle.id >= 0) {
switch (static_cast<ParamControl>(g.captionToggle.id)) {
case ParamControl::kPlayMode:
drawToggle(g.captionToggle, "Gate", "Trigger",
play.playMode == PlayMode::Trigger, false);
break;
case ParamControl::kPitchEngine:
drawToggle(g.captionToggle, "Varisp", "Presrv",
play.pitchEngine == PitchEngine::Preserve, false);
break;
case ParamControl::kPitchEnvEnable:
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false);
break;
case ParamControl::kVoiceMode:
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
break;
default: break;
}
}
// The row toggle (VOICE group's Retrig|Legato) — live only in Mono.
if (g.rowToggle.id >= 0) {
drawToggle(g.rowToggle, "Retrig", "Legato",
monoTrigger_ == MonoTrigger::Legato, !isMono);
}
// The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off —
// stable geometry (r11).
for (const DeckCellLayout& c : g.cells) {
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled);
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
const InteractionState st =
disabled ? InteractionState::Disabled
: (dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover : InteractionState::Rest));
drawKnobFace(bmp, c.knob, deckControlNorm(c.id, zone), st);
const std::string label = (dragging || hov)
? deckValueLabel(c.id, zone)
: std::string(knobName(static_cast<ParamControl>(c.id)));
kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim);
}
}
}
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r,
const PerformanceZone& zone) {
if (r.width <= 0 || r.height <= 0) return;
// The mini curve-preview button (r11/FB2 — shared by the Sample cluster and the Zone
// panel): a hairline-bordered bg/cell square with the zone's live velocity curve traced
// in miniature (no node markers at this scale). Hover lifts it; it draws ACTIVE
// (accent-primary border) while its popup is open, and re-renders live as the popup
// edits the curve (same zone, re-read each paint).
const bool hov = isHovered(HoverKind::kCurveButton, -1);
fillSurface(bmp, toKitBox(r), Role::BgCell,
hov ? InteractionState::Hover : InteractionState::Rest);
const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary)
: roleColor(Role::LineHairline);
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0);
const VelocityCurve& curve = zone.velocityCurve;
const int inset = 3;
const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset,
r.height - 2 * inset};
if (mini.width > 1 && mini.height > 1) {
const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary));
int prevX = 0, prevY = 0;
for (int px = 0; px <= mini.width; ++px) {
const int mx = mini.left + px;
const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity;
const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
prevX = mx;
prevY = my;
}
}
}
void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
// The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the
// Sample face stays legible behind it), then the centered sheet.
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0);
const CurvePopupLayout pl = computeCurvePopup(w, h);
fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest);
LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1,
pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0);
kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim);
{
const KitButtonBox box{toKitBox(pl.close)};
const InteractionState st = isHovered(HoverKind::kPopupClose, -1)
? InteractionState::Hover
: InteractionState::Rest;
drawButton(bmp, box, "x", st, /*warn=*/false);
}
// The full-size editor: ONE draw path + the one curveBoxFromRect mapping formula, so
// trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() —
// the picked capture's one-zone site on the Sample face, the selected zone on the Zone
// surface (FB2).
paintVelocityCurve(bmp, pl.curveBox, popupZone());
}
void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
// Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
// a bank filter that hides everything. Either way it is the "pick a capture" empty state.
const char* msg = samples_.empty()
? "No captures in this project yet - capture audio into the bank to play it here."
: "No captures in this bank filter. Choose another bank tab above.";
// Split the area so the primary line sits centered and the S13 ingest affordance sits just
// below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept
// discoverable here regardless of whether a drop ever lands on THIS window.
Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2);
Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom());
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
kitTextCentered(bmp, hint,
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
Font::Micro, Role::TextDim);
}
} // namespace reasampler::vst
#endif // _WIN32
+271
View File
@@ -0,0 +1,271 @@
// editor_platform.cpp — the ReaSamplerEditor's IPlugView + Win32 window plumbing (Q-W2v
// split of reasampler_editor.cpp, T4-11): platform-type/resize negotiation, the child
// window class + creation/destruction, the S9/S8 sync timer lifetime, the WM_* dispatch
// (wndProc — paint, mouse, keyboard, capture-loss rollback, drop-accept, timer), and the
// non-Windows stubs (D5 makes Windows the only build target; the TU still compiles
// elsewhere).
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — S13 drop-accept
#endif
#include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only)
#include "shell/instrument/reasampler_processor.h"
using namespace Steinberg;
namespace reasampler::vst {
#ifdef _WIN32
namespace {
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency
// UI-thread timer: responsive enough that a recapture/ingest/assign refreshes "within a
// bounded cadence" (the S9 verify criterion) yet cheap — three small ext-state reads per
// tick, coalescing many bumps between ticks into one reload. 500 ms is a deliberate
// build-time residual. The id is a per-window SetTimer id (any nonzero).
constexpr UINT_PTR kSyncTimerId = 1;
constexpr UINT kSyncTimerIntervalMs = 500;
} // namespace
#endif
tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) {
#ifdef _WIN32
if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue;
#endif
return kResultFalse;
}
tresult PLUGIN_API ReaSamplerEditor::canResize() {
return kResultTrue;
}
tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) {
// Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before
// every resize; clamp the proposed rect in place and return kResultTrue so the host applies the
// (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title
// + hero waveform + cluster + a few control rows visible (the control strip clips gracefully
// below the panel bottom); anything smaller would clip essential UI. The default 840×620 is
// above this floor.
constexpr int kMinW = 560;
constexpr int kMinH = 460;
if (!rect) return kResultFalse;
if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW;
if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH;
return kResultTrue;
}
#ifdef _WIN32
void ReaSamplerEditor::invalidate() {
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
}
void ReaSamplerEditor::attachedToParent() {
HWND parent = static_cast<HWND>(systemWindow);
if (!parent) return;
HINSTANCE hInst =
reinterpret_cast<HINSTANCE>(GetWindowLongPtr(parent, GWLP_HINSTANCE));
if (!hInst) hInst = GetModuleHandle(nullptr);
static bool classRegistered = false;
if (!classRegistered) {
WNDCLASSW wc{};
wc.lpfnWndProc = &ReaSamplerEditor::wndProc;
wc.hInstance = hInst;
wc.lpszClassName = kChildClassName;
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
wc.style = CS_HREDRAW | CS_VREDRAW;
RegisterClassW(&wc);
classRegistered = true;
}
// Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a
// reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on
// editor close: the embed strip in the SAME binary shares the kit's process-global font
// set, so a per-view shutdown could free fonts still in use by the other view. The tiny
// static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note.
kitFontsInit();
refreshFromBank();
const ViewRect& r = getRect();
childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0,
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
if (childHwnd_) {
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
// S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT
// ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show
// the "drop on the panel" affordance instead of the OS bouncing the drop silently.
DragAcceptFiles(childHwnd_, TRUE);
// Start the S9/S8 change-detection poll (UI thread). Tied to the child window's
// lifetime — created here, killed in removedFromParent — so an instance whose editor
// is closed does NOT poll (the editor-open-only cadence; see the handoff limitation).
SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr);
// Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor
// was closed) or a bank change applies the instant the editor opens, rather than waiting
// up to one timer interval. refreshFromBank above already primed the view; this folds in
// any pending assign/generation so the just-opened editor shows the assigned capture.
onSyncTimer();
}
}
void ReaSamplerEditor::removedFromParent() {
if (childHwnd_) {
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
}
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
tresult res = CPluginView::onSize(newSize);
if (childHwnd_ && newSize) {
MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them
}
return res;
}
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
LPARAM lParam) {
auto* self =
reinterpret_cast<ReaSamplerEditor*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));
switch (msg) {
case WM_PAINT: {
PAINTSTRUCT ps{};
HDC hdc = BeginPaint(hwnd, &ps);
if (self) self->paint(hdc);
EndPaint(hwnd, &ps);
return 0;
}
case WM_LBUTTONDOWN:
if (self) {
SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child
SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12)
self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
}
return 0;
case WM_MOUSEMOVE:
if (self) {
const int mx = GET_X_LPARAM(lParam);
const int my = GET_Y_LPARAM(lParam);
// Hover feedback (Phase L, L3): resolve the element under the pointer and
// repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover
// clears when the pointer leaves the child (TrackMouseEvent is one-shot).
if (!self->mouseTracking_) {
TRACKMOUSEEVENT tme{};
tme.cbSize = sizeof(tme);
tme.dwFlags = TME_LEAVE;
tme.hwndTrack = hwnd;
TrackMouseEvent(&tme);
self->mouseTracking_ = true;
}
// While a drag is in flight the drag owns the surface; skip hover resolution
// (a hover repaint mid-drag would fight the live drag feedback).
if (self->drag_ == DragKind::kNone) self->resolveHover(mx, my);
self->onMouseMove(mx, my);
}
return 0;
case WM_MOUSELEAVE:
if (self) {
self->mouseTracking_ = false;
if (self->hover_.kind != HoverKind::kNone) {
self->hover_ = HoverTarget{};
self->invalidate();
}
}
return 0;
case WM_MOUSEWHEEL:
// S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up.
if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam));
return 0;
case WM_CHAR:
// S12 type-to-filter search keystrokes (only acted on when the search box is focused).
if (self) self->onSearchChar(static_cast<unsigned int>(wParam));
return 0;
case WM_GETDLGCODE:
// Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search).
return DLGC_WANTCHARS | DLGC_WANTARROWS;
case WM_LBUTTONUP:
if (self) {
self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
ReleaseCapture();
}
return 0;
case WM_RBUTTONDOWN:
// r11: right-click — the curve popup's primary node-delete affordance (issue 3c).
// Routed explicitly (the child wndproc historically handled only left-button).
if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
return 0;
case WM_RBUTTONUP:
return 0; // claimed so the pair never reaches DefWindowProc (no context menu)
case WM_CAPTURECHANGED:
// Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its
// pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset
// the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
// Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler.
if (self) {
// A held preview note must be released here too (peer of WM_LBUTTONUP) — capture
// loss otherwise leaves the momentary-key voice hung with no note-off.
if (self->previewingNote_ >= 0) {
if (self->processor_) self->processor_->previewNoteOff(self->previewingNote_);
self->previewingNote_ = -1;
self->invalidate();
}
if (self->drag_ != DragKind::kNone) {
// A scrollbar drag + the processor-side deck knobs (preview velocity -2 /
// voice count / master gain) are transient (no map mutation; dragStartMap_
// not snapshotted) — reset drag state only, never touch map_. Every
// map-editing drag rolls its live mutation back to the snapshot.
const bool transient = self->drag_ == DragKind::kScrollThumb ||
(self->drag_ == DragKind::kDeckKnob &&
(self->dragParamId_ == -2 ||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain)));
if (!transient) self->map_ = self->dragStartMap_;
self->drag_ = DragKind::kNone;
self->dragParamId_ = -1;
self->dragParamZone_ = -1;
self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset)
self->dragCurveZone_ = -1;
self->invalidate();
}
}
return 0;
case WM_DROPFILES: {
// S13 (relay degraded): count the dropped files and flash the affordance. We do NOT
// read/ingest the paths (the instrument never ingests — the relay to the extension is
// unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner.
HDROP drop = reinterpret_cast<HDROP>(wParam);
const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
DragFinish(drop);
if (self) self->onFilesDropped(static_cast<int>(count));
return 0;
}
case WM_TIMER:
if (self && wParam == kSyncTimerId) self->onSyncTimer();
return 0;
case WM_ERASEBKGND:
return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase
default:
return DefWindowProcW(hwnd, msg, wParam, lParam);
}
}
#else // non-Windows: not a build target (D5), but keep the TU compilable.
void ReaSamplerEditor::attachedToParent() {}
void ReaSamplerEditor::removedFromParent() {}
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
return CPluginView::onSize(newSize);
}
#endif // _WIN32
} // namespace reasampler::vst
+371
View File
@@ -0,0 +1,371 @@
// editor_session.cpp — the ReaSamplerEditor's SESSION/BRIDGE state (Q-W2v split of
// reasampler_editor.cpp, T4-11): construction, the live-bank snapshot (refreshFromBank /
// rebuildVisible), the S9/S8 sync tick, the commit-and-reload seam, selection loading,
// the picked-capture marker resolution/upsert helpers, and the decoded-PCM + peak
// thumbnail caches (the mirror of bank_panel's, keyed through the pure ThumbnailKey —
// T2-10 rider). UI thread only; every edit commits OFF the audio thread via the
// processor's reloadInstrument.
#include "shell/instrument/reasampler_editor.h"
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#include "core/audio/peaks.h" // computeEnvelope (the cached peak thumbnail)
#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution)
#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames
#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (T2-10: the pure key)
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
#include "ext_keys.h"
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (S12 type-to-filter)
#include "shell/instrument/reaper_bridge.h"
#include "shell/instrument/reasampler_processor.h"
using namespace Steinberg;
namespace reasampler::vst {
using namespace reasampler::instrument::map; // sample_map vocabulary (selectSample / listSamples / …)
using audio::computeEnvelope;
using capture::WavLayout;
using capture::extractFloatFrames;
using capture::parseWavLayout;
using capture::resolveBankFile;
using instrument::ui::nameMatchesQuery;
using ui::ThumbnailKey;
using ui::thumbnailKeyString;
using util::readFileBytes;
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) {
// Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample
// home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate
// mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll
// on a 1080p screen with headroom. Wide enough that the control strip's label + value columns
// read comfortably.
ViewRect r(0, 0, 840, 620);
setRect(r);
}
void ReaSamplerEditor::refreshFromBank() {
// Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER).
thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks
pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source)
if (!processor_) {
samples_.clear();
banks_.clear();
visible_.clear();
selectedId_.clear();
map_.zones.clear();
selectedZone_ = -1;
return;
}
auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
selectedId_ = processor_->selectedSampleId();
const auto prevZoneCount = static_cast<int>(map_.zones.size());
map_ = processor_->performanceMap();
channelMode_ = processor_->channelMode();
voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot
voiceMode_ = processor_->voiceMode();
monoTrigger_ = processor_->monoTrigger();
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
// r11: a refresh that emptied the selection (a bank change on the sync tick) closes the
// curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible
// modal would swallow clicks.
if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false;
// FB2: on the Zone surface the popup edits the SELECTED zone; close it if the zones list
// shrank (selectedZone_ past-end), OR if the zone count changed at all — a mid-list
// deletion leaves selectedZone_ in range but now naming a DIFFERENT zone (silent retarget).
if (view_ == View::kZone && curvePopupOpen_) {
const auto newZoneCount = static_cast<int>(map_.zones.size());
if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false;
}
// Drop a filter that names a bank no longer present.
if (!activeFilterBankId_.empty()) {
bool found = false;
for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true;
if (!found) activeFilterBankId_.clear();
}
rebuildVisible();
}
void ReaSamplerEditor::rebuildVisible() {
// S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search
// narrows the survivors by name substring (nameMatchesQuery — empty query is the identity).
visible_.clear();
for (const SampleChoice& s : samples_) {
const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_;
if (!inBank) continue;
const std::string& name = s.displayName.empty() ? s.id : s.displayName;
if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s);
}
// NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel
// height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it
// must not reset the user's scroll here.
}
#ifdef _WIN32
// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared
// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes
// Windows the only build target, but the TU must still compile elsewhere).
void ReaSamplerEditor::onSyncTimer() {
// UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the
// processor (off the audio thread — the poll itself never touches process()). NEVER while a
// drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the
// user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release.
if (!processor_) return;
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
// An open editor marks THIS instance the focused assignment target (the thundering-herd
// policy — only an editor-open instance applies a pending assign; see the handoff). Pass
// true so this instance consumes the request; instances with no editor open do not poll at
// all (the timer is bound to the child window), so they never contend for the request.
const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true);
// Re-snapshot the editor's own view only when something changed (a reload from a bank
// content change, or an applied assignment). refreshFromBank re-reads the bank blob + the
// processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM
// caches, then repaints — so the browser + setup surface reflect the new bank hands-free.
if (r.reloaded || r.applied) {
refreshFromBank();
invalidate();
}
// S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop.
if (dropHintTicks_ > 0) {
--dropHintTicks_;
invalidate();
}
}
#endif // _WIN32
void ReaSamplerEditor::commitAndReload() {
// UI thread only. Publish the edited selection + zones to the processor, then rebuild
// the instrument off the audio thread (reloadInstrument bakes them into the live Keymap).
// pS: the reload also COPIES the picked capture's file ref + intrinsics from the bank
// blob into the instance-owned refs table (refreshRefsFromBank) — a browser load is the
// moment the instance becomes self-contained for that sample.
if (!processor_) return;
processor_->setSelectedSampleId(selectedId_);
processor_->setPerformanceMap(map_);
processor_->reloadInstrument();
// GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's
// channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode
// the engine actually decoded with.
channelMode_ = processor_->channelMode();
#ifdef _WIN32
invalidate();
#endif
}
void ReaSamplerEditor::loadSelection(const std::string& id) {
// Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous
// sample's materialized full-range zone must not linger — first-match resolve would
// keep playing it while the editor draws the new pick's zone (matched by sampleId,
// order-blind). Authored Zone-view maps (any narrow key range) are left untouched.
selectedId_ = id;
if (reconcileSingleCaptureZones(map_, selectedId_)) {
selectedZone_ = map_.zones.empty() ? -1 : 0;
}
commitAndReload();
}
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
SetupMarkers m;
// Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override
// for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic
// from the live bank blob (the same path selectSample uses); when that is not readable
// (extension absent / not yet parsed) the instance-OWNED ref carries the same intrinsics
// (pS fallback). The override lives in map_.
if (processor_) {
std::optional<SelectedSample> sel;
auto banksJson =
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
if (banksJson) sel = selectSample(*banksJson, selectedId_);
if (!sel) {
const SampleRefs refs = processor_->sampleRefs();
if (const SelectedSample* r = findRef(refs, selectedId_)) sel = *r;
}
if (sel && sel->loop.hasLoop) {
m.hasLoop = true;
m.loopStart = sel->loop.start;
m.loopEnd = sel->loop.end;
}
}
// The override (loop + start) on a zone for the picked id supersedes the intrinsic.
for (const PerformanceZone& z : map_.zones) {
if (z.sampleId != selectedId_) continue;
if (z.loopOverride) {
m.hasLoop = z.loopOverride->hasLoop;
m.loopStart = z.loopOverride->start;
m.loopEnd = z.loopOverride->end;
}
if (z.startPoint) m.start = *z.startPoint;
break;
}
// Default an unset loop's end to the sample length so the loop markers have somewhere sane
// to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false;
// the markers are still drawn (drag one to CREATE a loop).
if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0;
return m;
}
int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
// Find-or-append the zone for selectedId_ and write the loop/start override fields.
// The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must
// be non-empty; callers are responsible for that guard.
// Returns the zone index (0-based) so callers can update selectedZone_.
SampleLoop loop;
loop.hasLoop = m.hasLoop;
loop.start = m.loopStart;
loop.end = m.loopEnd;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
if (z.sampleId == selectedId_) {
z.loopOverride = loop;
z.startPoint = m.start;
return i;
}
}
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
z.loopOverride = loop;
z.startPoint = m.start;
map_.zones.push_back(z);
return static_cast<int>(map_.zones.size()) - 1;
}
PerformanceZone ReaSamplerEditor::effectiveSampleZone() const {
// The picked id's one-zone override, if the map already carries one; else a product-default
// zone bound to the picked id (NOT appended — a read-only resolve; a control edit materializes
// it via ensureSampleZone). Mirrors the S15-F2 single-storage-site lean.
for (const PerformanceZone& z : map_.zones) {
if (z.sampleId == selectedId_) return z;
}
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
return z;
}
int ReaSamplerEditor::effectiveRoot() const {
int root = 60;
for (const SampleChoice& s : samples_) {
if (s.id == selectedId_ && s.rootNote) root = *s.rootNote;
}
for (const PerformanceZone& z : map_.zones) {
if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride;
}
return root;
}
int ReaSamplerEditor::ensureSampleZone() {
if (selectedId_.empty()) return -1;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
if (map_.zones[static_cast<std::size_t>(i)].sampleId == selectedId_) return i;
}
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
map_.zones.push_back(z);
return static_cast<int>(map_.zones.size()) - 1;
}
void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert,
// mirror of the root-marker path): a full-keyboard zone carrying the override. This plays
// identically to the un-zoned single capture (one chromatic zone) and round-trips through
// the component state; the zone becomes visible if the user opens the Zones panel. The bank
// intrinsic is NEVER written (read-only bank consumer, D-B).
if (selectedId_.empty()) return;
upsertPickedOverride(m);
commitAndReload();
}
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
auto it = pcmCache_.find(sampleId);
if (it != pcmCache_.end()) return it->second;
// SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so
// resolve the path from the live bank blob (selectSample) and decode via the shared WAV
// parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY
// vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width-
// independent) — the thumbnail bins this at whatever width, the snap scans it directly.
std::string relativePath;
std::vector<AudioSample> mono;
if (processor_) {
auto banksJson =
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
if (banksJson) {
if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath;
}
if (relativePath.empty()) {
// pS fallback: the bank blob is not readable (extension absent / not yet parsed)
// or the id went stale there — the instance-OWNED ref still carries the path, so
// a self-contained instance draws its loaded sound's waveform regardless.
const SampleRefs refs = processor_->sampleRefs();
if (const SelectedSample* r = findRef(refs, sampleId)) {
relativePath = r->relativePath;
}
}
if (!relativePath.empty()) {
const std::string projectDir = processor_->bridge().activeProjectDir();
const std::string abs = resolveBankFile(projectDir, relativePath);
// Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure.
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
const WavLayout layout = parseWavLayout(bytes);
if (layout.valid) {
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount());
mono = downmixToMono(interleaved, layout.channelCount);
}
}
}
auto ins = pcmCache_.emplace(sampleId, std::move(mono));
return ins.first->second;
}
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
// T2-10 rider: key through the PURE ThumbnailKey (bank_grid) instead of the former
// ad-hoc "id|binCount" concat, so both thumbnail pipelines share one tested key
// grammar (length-prefixed id — collision-proof). The editor invalidates by wholesale
// clear() on refresh/resize, so the bank generation carries no information here — 0.
const std::string key =
thumbnailKeyString(ThumbnailKey{sampleId, binCount, /*generation=*/0});
auto it = thumbCache_.find(key);
if (it != thumbCache_.end()) return it->second;
// Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by
// every thumbnail width AND the S11 waveform surface + snap.
const std::vector<AudioSample>& mono = monoPcmFor(sampleId);
Envelope env;
if (!mono.empty()) {
// Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with
// trailing empty {0,0} bins, which would render a very short sample as a comb of
// spikes over flat gaps.
const std::size_t bins =
(std::min)(static_cast<std::size_t>((std::max)(1, binCount)), mono.size());
env = computeEnvelope(mono, 1, mono.size(), bins);
}
auto ins = thumbCache_.emplace(key, std::move(env));
return ins.first->second;
}
ReaSamplerEditor::~ReaSamplerEditor() {
#ifdef _WIN32
if (childHwnd_) {
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
#endif
}
} // namespace reasampler::vst
+489
View File
@@ -0,0 +1,489 @@
// processor_reload.cpp — the ReaSamplerProcessor's OFF-AUDIO-THREAD instrument
// lifecycle: reloadInstrument (self-contained refs resolve + WAV decode + keymap
// build), the safety-critical publishBuiltLocked drain-slot swap, the voice-param
// light rebuild, idle-drain retirement, the pre-v10 legacy-lift gate, the S9/S8
// bank-sync poll, and the pS-usage publish. Split out of reasampler_processor.cpp
// (Q-W2v, T4-12). NOTHING here runs on the audio thread — process() (the lifecycle
// TU) only touches the atomics this family publishes; the atomic-pointer-swap
// pattern deliberately gains NO virtual seam (T4-29).
#include "shell/instrument/reasampler_processor.h"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <mutex>
#include <optional>
#include <random>
#include <utility>
#include <vector>
#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution)
#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
#include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision
#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (pS self-contained)
#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03)
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (S8 request wire parse)
#include "core/wire/sample_usage.h" // pS-usage publish plan + wire (prune-protection seam)
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey
namespace reasampler::vst {
using namespace instrument::map; // resolution + bank-sync vocabulary this TU drives
using namespace reasampler::wire; // assignment_request + sample_usage wire records
namespace {
// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather
// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice
// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the
// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays
// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget.
constexpr std::size_t kPreserveVoiceCap = 8;
// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy
// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the
// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic
// strength) is the requirement: two instances sharing a key is the copy-collision
// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision
// vanishingly unlikely. Off-thread only.
std::string mintUsageInstanceGuid() {
std::random_device rd;
std::mt19937_64 gen((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
std::uniform_int_distribution<std::uint64_t> dist;
char buf[33] = {0};
std::snprintf(buf, sizeof(buf), "%016llx%016llx",
static_cast<unsigned long long>(dist(gen)),
static_cast<unsigned long long>(dist(gen)));
return std::string(buf);
}
// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03).
// Off-thread only (blocking file I/O). Empty on any failure — the caller treats
// an unreadable WAV as "nothing to play".
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode
// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for
// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path
// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no
// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by
// the zoned build and the single-capture path so both decode identically for the active mode.
std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
const std::string& relativePath,
ChannelMode mode) {
const std::string abs = resolveBankFile(projectDir, relativePath);
if (abs.empty()) return std::nullopt;
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
const WavLayout layout = parseWavLayout(bytes);
if (!layout.valid) return std::nullopt;
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount());
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
static_cast<int>(layout.sampleRate));
if (out.monoFrames.empty()) return std::nullopt;
return out;
}
} // namespace
std::string ReaSamplerProcessor::reloadInstrument() {
// OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so
// the retired-slot free is single-writer. This mutex is NEVER taken on the audio
// thread — process() only touches the atomic.
std::lock_guard<std::mutex> lock(reloadMutex_);
// Mint this reload's generation number first so we can stamp the built instrument
// with it before publishing. Under reloadMutex_ no other reload races here.
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
// 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of
// truth for what to decode. The live bank blob, WHEN readable, is folded into the
// table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in
// mechanism and the S9 recapture sync in one — but its absence changes NOTHING
// below: a project restored before the extension's PROJEXTSTATE parses (or with
// the extension absent entirely) resolves + plays from the persisted refs. The
// project dir comes from REAPER itself (EnumProjects), not from the extension.
const std::string selId = selectedSampleId();
const PerformanceMap map = performanceMap();
const std::vector<std::string> ids = referencedSampleIds(selId, map);
SampleRefs refs;
{
std::optional<std::string> banksJson =
bridge_.readReasamplerExtState(kProjExtBanksKey);
std::lock_guard<std::mutex> rl(refsMutex_);
if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids);
// The LOAD path never prunes the owned table: dropping entries here on a transient
// bank miss could destroy the owned intrinsics of the previous selection — the ONE
// copy that survives with the extension absent. Entries for de-referenced ids stay
// in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary,
// where getState filters its snapshot via retainRefs to what the instance plays.
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
}
const std::string projectDir = bridge_.activeProjectDir();
// The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel).
// Read once under its mutex, off the audio thread, before the decode loop. The single-
// capture branch below may auto-default it (GA) before its decode.
ChannelMode mode = channelMode();
// Phase S: snapshot the voice-system parameters once — they are baked into the built
// engine's construction (the engine's config is immutable; a later change rebuilds).
int builtVoiceCount = kDefaultVoiceCount;
VoiceMode builtVoiceMode = VoiceMode::Poly;
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
{
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
builtVoiceCount = voiceCount_;
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
std::string resolvedId;
std::unique_ptr<LoadedInstrument> built;
Keymap km;
bool haveKeymap = false;
// 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its
// zones against the OWNED refs (an id with no ref drops cleanly), decode each
// zone's WAV off-thread, and build the ZONED keymap. Each surviving zone plays
// its sample repitched from its effective root note (override > ref intrinsic >
// C4). A zone whose WAV fails to decode — a MISSING FILE included — is dropped
// (not the whole map): the defined no-play, no crash, no retry loop.
if (!map.empty()) {
const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map);
if (!resolved.zones.empty()) {
std::vector<DecodedZonePcm> decoded;
std::vector<ResolvedZone> kept;
decoded.reserve(resolved.zones.size());
kept.reserve(resolved.zones.size());
for (const ResolvedZone& rz : resolved.zones) {
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, rz.relativePath, mode);
if (!pcm) continue; // unreadable/missing WAV -> drop this zone
kept.push_back(rz);
decoded.push_back(std::move(*pcm));
}
km = buildZonedKeymap(kept, decoded);
haveKeymap = !km.zones.empty();
}
}
// 3. Single-capture fast path (S10): an empty performance map plays the ONE
// deliberately-selected capture chromatically across the whole keyboard, resolved
// against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a
// selection with no ref) resolves to nothing, so an un-picked instrument stays
// SILENT (the editor shows its "pick a capture" empty state) rather than
// auto-playing sample #1 (S10 policy reversal of the S4 convenience default).
if (!haveKeymap) {
if (const SelectedSample* sel = findRef(refs, selId)) {
// GA auto-default: channelModeFor computes the mode from the loaded capture's
// REQUESTED channel count (always 2 for extension captures; mono only for
// ingest-imported mono files). An unknown count (0) or explicit user choice
// returns the current mode unchanged. Decode-only: the output bus is fixed
// stereo, so no bus work follows a flip.
{
std::lock_guard<std::mutex> cm(channelModeMutex_);
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
channelModeExplicit_);
mode = channelMode_;
}
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) {
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
sel->rootNote, sel->loop,
std::move(pcm->framesR));
haveKeymap = true;
resolvedId = selId; // the concrete pick that resolved
}
}
}
if (haveKeymap) {
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
// Every voice's shifter is pre-sized to this off-thread here, so process()-time
// note-on never allocates. Floored at 2 so a valid window is always a real ring
// (which also covers a pathological host rate <= 0 — no rate literal needed).
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
built = std::make_unique<LoadedInstrument>(
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
}
// 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the
// DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices —
// a reload never cuts a sounding note; the next note-on plays the new state. The
// instrument evicted FROM the drain slot (two reloads old) goes to the graveyard
// (process may still be mid-block reading it). A null `built` (no ref / unreadable
// WAV) installs silence while the displaced tails still ring out via the drain.
// `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted
// pointer is re-owned by the graveyard.
publishBuiltLocked(std::move(built));
// 5. pS-usage: publish this instance's held captures so the extension's prune can
// never reclaim them (see publishUsage). AFTER the instrument swap, still off the
// audio thread and under reloadMutex_. Publishes regardless of decode success:
// the holds are the refs the instance RETAINS (its play-set), not what decoded —
// a transiently unreadable WAV must stay protected.
publishUsage(refs, ids);
return resolvedId;
}
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
const std::vector<std::string>& ids) {
if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do
UsageRecord mine;
mine.trackGuid = bridge_.currentTrackGuid();
for (const std::string& id : ids) {
if (const SelectedSample* ref = findRef(refs, id)) {
if (!ref->relativePath.empty()) {
mine.holds.push_back(UsageHold{id, ref->relativePath});
}
}
}
std::lock_guard<std::mutex> lock(usageMutex_);
// A never-published instance with nothing held writes nothing — no key litter for
// fresh/empty instances. Once an identity exists, empties DO publish (they release
// holds the prune would otherwise keep protecting).
if (instanceGuid_.empty() && mine.holds.empty()) return;
if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
// The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so
// planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track
// sibling's byte-identical hold set can never pass as ours (its nonce differs), so
// siblings always union and never clean-replace over each other's held paths.
if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid();
mine.ownerNonce = usageNonce_;
const std::optional<std::string> existing =
bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_));
const UsagePublishPlan plan = planUsagePublish(existing, mine);
if (plan.remint) {
// This state was cloned onto another track (FX copy / track duplication): take a
// fresh identity and leave the original's record untouched. The abandoned old
// identity's record dies by the extension's liveness rule when its track no
// longer hosts an instance. getState persists the new guid on the next save.
instanceGuid_ = mintUsageInstanceGuid();
} else if (plan.skipWrite) {
return; // idle tick, or a union that adds nothing — no ext-state churn
}
bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire);
}
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
// reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance.
//
// Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is
// the minimum installedAt process() published over the pointers it holds. Both
// slots are monotone in installedAt, so seen is monotone and any future process()
// load yields installedAt >= seen — an entry below seen is provably unreachable
// (see the header proof). Remaining entries drain at setActive(false) / terminate()
// when process is guaranteed stopped.
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
graveyard_.erase(
std::remove_if(graveyard_.begin(), graveyard_.end(),
[seen](const std::unique_ptr<LoadedInstrument>& e) {
return e->installedAt < seen;
}),
graveyard_.end());
LoadedInstrument* prev = live_.exchange(built.release());
LoadedInstrument* evicted = draining_.exchange(prev);
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
}
void ReaSamplerProcessor::rebuildVoiceEngine() {
// OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract:
// a voice-param change touches NO audio data, so this rebuilds the engine
// around a COPY of the live instrument's already-decoded keymap — no bridge, no disk —
// and publishes through the same drain-slot swap, so ringing tails survive.
std::lock_guard<std::mutex> lock(reloadMutex_);
LoadedInstrument* cur = live_.load(std::memory_order_acquire);
if (!cur) return; // nothing loaded: the new params bake into the next real reload.
int builtVoiceCount = kDefaultVoiceCount;
VoiceMode builtVoiceMode = VoiceMode::Poly;
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
{
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
builtVoiceCount = voiceCount_;
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
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).
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
// Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap
// is immutable after construction, and under reloadMutex_ nobody can free `cur`.
Keymap km = cur->keymap;
auto built = std::make_unique<LoadedInstrument>(
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
publishBuiltLocked(std::move(built));
}
void ReaSamplerProcessor::retireIdleDrain() {
// Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or
// it still sounds" — the common case costs one relaxed load and no mutex.
const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire);
if (idleGen == 0) return;
std::lock_guard<std::mutex> lock(reloadMutex_);
LoadedInstrument* drain = draining_.load(std::memory_order_acquire);
// Retire ONLY if the publication names the drain currently in the slot. A stale value
// (about an already-evicted, older drain) can never match the newer occupant's
// installedAt — the slot is monotone in generation — so a mid-swap race is closed by
// this identity check, not by timing.
if (!drain || drain->installedAt != idleGen) return;
draining_.store(nullptr, std::memory_order_release);
graveyard_.push_back(std::unique_ptr<LoadedInstrument>(drain));
// Prune what is now provably unreachable — the same monotone-generation proof as the
// reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be
// held by process() now or ever again. The just-parked drain frees here immediately when
// process() has already published past it; otherwise on the next reload/retire/deactivate.
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
graveyard_.erase(
std::remove_if(graveyard_.begin(), graveyard_.end(),
[seen](const std::unique_ptr<LoadedInstrument>& e) {
return e->installedAt < seen;
}),
graveyard_.end());
}
bool ReaSamplerProcessor::legacyLiftShouldRun() {
// #A terminating guard for the pre-v10 legacy lift. The caller has already established
// refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before
// paying for a full reload. Once concluded, the steady state is this one relaxed load —
// no bank read, no parse, no reload churn.
if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
const LegacyLiftDecision decision = legacyLiftDecision(
bridge_.readReasamplerExtState(kProjExtBanksKey),
referencedSampleIds(selectedSampleId(), performanceMap()));
if (decision == LegacyLiftDecision::Stale) {
// Provably stale (the bank parses and knows none of the referenced ids): give up
// PERMANENTLY. A later bank change that re-introduces an id bumps the generation,
// and the genChanged reload refreshes the refs without consulting this latch.
legacyLiftConcluded_.store(true, std::memory_order_relaxed);
return false;
}
return true; // Retry (blob not readable yet) or Lift (a ref can be copied in)
}
ReaSamplerProcessor::BankSyncResult
ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
// OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call
// REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER
// host, or before connect) yields nullopt for both reads, so this no-ops cleanly.
BankSyncResult result;
// Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer
// cadence that drives reloads — an edited-away instrument stops costing memory as soon
// as its tails die instead of squatting in the drain slot until the next reload.
retireIdleDrain();
// --- S8: assignment-request consume FIRST -------------------------------------
// Decode the pending assignment request (nullopt when absent/malformed). Resolve its
// (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when
// the sampleId names an existing sample (the reader requirement — an unresolvable pair is
// dropped). Then run the pure consume decision against this instance's persisted marker.
std::optional<AssignmentRequest> request;
if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) {
request = decodeAssignmentRequest(*raw);
}
bool resolves = false;
if (request) {
// Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request
// whose sample was rolled back by an extension undo resolves to nullopt -> dropped).
if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) {
resolves = selectSample(*banksJson, request->sampleId).has_value();
}
}
// Read lastConsumed and conditionally write it back under a single lock scope so there
// is no interleave window between the read and the write (a concurrent getState could
// otherwise observe a stale marker between the two separate lock acquisitions).
std::int64_t lastConsumed = 0;
const AssignConsumeDecision decision = [&] {
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
lastConsumed = lastConsumedAssignGeneration_;
const AssignConsumeDecision d =
consumeDecision(request, lastConsumed, resolves, isFocusedTarget);
// Advance the persisted consumed marker whenever the decision consumed the request
// (applied OR dropped-as-seen). getState will persist it on the next project save so
// a re-open does not re-apply. A non-target instance leaves the marker (decision
// returns it unchanged) so it stays eligible if focus later lands here.
if (d.consumedGeneration != lastConsumed) {
lastConsumedAssignGeneration_ = d.consumedGeneration;
}
return d;
}();
if (decision.apply) {
// Apply the assignment as this instance's own selection (the same path a user card-pick
// takes) — the instrument updates its OWN state, never the bank. reloadInstrument below
// rebuilds against the new selection, so skip a redundant reload here.
setSelectedSampleId(decision.sampleId);
// Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone
// materialized for the previously loaded sample would shadow the assigned pick under
// first-match resolve. Authored maps (any narrow key range) are untouched.
PerformanceMap reconciled = performanceMap();
if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) {
setPerformanceMap(reconciled);
}
result.applied = true;
}
// --- S9: bank-generation change-detection -------------------------------------
// Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll
// (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload —
// setState already loaded the instrument from its OWNED refs (pS), so a redundant reload
// on open would only churn. A later
// generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the
// reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced).
std::int64_t currentGen = kBankGenerationAbsent;
if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) {
currentGen = parseBankGeneration(*rawGen);
}
const bool firstPoll = (lastSeenBankGeneration_ < 0);
const bool genChanged =
!firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen);
lastSeenBankGeneration_ = currentGen;
// LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones)
// but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had
// nothing to decode unless the bank happened to be readable already. Reload on this
// editor tick until the lift lands: reloadInstrument folds the bank blob into the refs
// when readable, after which the table is non-empty and this never fires again (the
// next save is then self-contained). A deliberately-empty instance has no intent and
// never churns; a bank that is not readable YET retries a cheap null publish on the
// editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob
// PARSES and no referenced id resolves in it, the ids are provably stale — there is
// nothing to lift, so the lift concludes permanently instead of churning a full bank
// read + reload every tick forever. This is a MIGRATION convenience for old projects,
// NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS).
bool legacyLift = false;
if (!genChanged && !result.applied && sampleRefs().empty()) {
const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty();
legacyLift = hasIntent && legacyLiftShouldRun();
}
if (genChanged || result.applied || legacyLift) {
reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard)
// Report the reload distinctly from an S8 apply so the editor re-snapshots its bank
// view. A legacy lift counts only when it actually landed an instrument (otherwise
// every retry tick would churn the editor's caches for nothing).
result.reloaded =
genChanged ||
(legacyLift && live_.load(std::memory_order_acquire) != nullptr);
}
return result;
}
} // namespace reasampler::vst
+311
View File
@@ -0,0 +1,311 @@
// processor_state.cpp — the ReaSamplerProcessor's COMPONENT-STATE I/O (setState /
// getState against the component_state_io codec) and its UI-thread parameter
// accessors/setters (selection, performance map, channel mode, preview velocity,
// voice-system params, master gain, preview-note mailbox posts). Split out of
// reasampler_processor.cpp (Q-W2v, T4-12). Everything here runs OFF the audio
// thread (UI / host load-save); the setters hand work to the reload family
// (processor_reload.cpp) or store atomics process() picks up at block start.
#include "shell/instrument/reasampler_processor.h"
#include <cstdint>
#include <mutex>
#include <vector>
#include "pluginterfaces/base/ibstream.h"
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp)
#include "core/instrument/map/component_state_io.h" // the ComponentState codec (Q-W2v split)
#include "core/instrument/map/sample_map.h" // reconcileSingleCaptureZones / retainRefs / referencedSampleIds
using namespace Steinberg;
using namespace Steinberg::Vst;
namespace reasampler::vst {
using namespace instrument::map; // the codec + resolution vocabulary this TU marshals
tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
if (!state) return kResultFalse;
// Read the whole component-state blob (the performance map, versioned). The blob is
// small; read in one shot into a growable buffer.
std::vector<std::uint8_t> bytes;
std::uint8_t chunk[256];
int32 got = 0;
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
bytes.insert(bytes.end(), chunk, chunk + got);
}
// Component state (v3, S10) is {single-capture selection id, opt-in zones}. The
// selection and the zones are DISTINCT — the default face is one picked capture, zones
// are a demoted overlay — so both are restored explicitly (no more inferring a selection
// from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only
// blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so
// the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10
// silent empty state (no first-sample fallback in reloadInstrument).
// Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at
// the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing
// before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a
// pre-setup call would assert inside readZonesPayload (a programming error, not a field case).
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
setSelectedSampleId(cs.selectionId);
// Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a
// pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the
// saved selection under first-match resolve. Reconciling here restores "the sample the
// editor shows is the sample the engine plays" for already-affected projects; authored
// Zone-view maps (any narrow key range) pass through untouched.
PerformanceMap restored = cs.map;
reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadInstrument run unconditionally on load
setPerformanceMap(restored);
// S8: restore the last-consumed assignment generation so a re-open does not re-apply a
// stale assign_request (the user may have manually changed the selection after the assign).
{
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration;
}
// Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see
// initialize) — the mode only governs how the reload below decodes, so no bus work here.
{
std::lock_guard<std::mutex> lock(channelModeMutex_);
channelMode_ = cs.channelMode;
channelModeExplicit_ = cs.channelModeExplicit;
}
// S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2
// the editor's velocity knob is a concurrent UI-thread writer.
{
std::lock_guard<std::mutex> lock(previewMutex_);
previewVelocity_ = cs.previewVelocity;
}
// Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly,
// Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the
// reload below so the rebuilt engine is born with the saved polyphony/mode.
{
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
voiceCount_ = cs.voiceCount;
voiceMode_ = cs.voiceMode;
monoTrigger_ = cs.monoTrigger;
}
// FB1: restore the post-mixer master gain (v8; older blobs lift to unity in
// deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks
// it up at the next block start.
setMasterGainLinear(cs.masterGainLinear);
// pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the
// reload so it decodes straight from them — no bank read required to play. A pre-v10
// blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob
// becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift),
// after which the next save is self-contained.
{
std::lock_guard<std::mutex> lock(refsMutex_);
sampleRefs_ = cs.sampleRefs;
}
// pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty —
// minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME
// for the copy-collision analysis (the fresh nonce means this incarnation can never
// be mistaken for the previous one's writes — or for a copy-sibling's).
{
std::lock_guard<std::mutex> lock(usageMutex_);
instanceGuid_ = cs.instanceGuid;
usageNonce_.clear();
}
// A new blob is new facts: a staleness proof latched against the PREVIOUS state does
// not carry over (#A — the legacy lift gets one fresh run per restored state).
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
// Rebuild from the restored state (off-thread — setState is a load-time call).
reloadInstrument();
return kResultOk;
}
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
if (!state) return kResultFalse;
// Persist the full instance state (v3, S10): the single-capture selection id AND the
// opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler"
// bank ext-state. An instance with no pick and no zones serializes to {"", no zones}
// and restores as the S10 empty state (silence + "pick a capture"), never auto-playing
// sample #1.
ComponentState state_out;
state_out.selectionId = selectedSampleId();
state_out.map = performanceMap();
{
// S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9).
std::lock_guard<std::mutex> lock(channelModeMutex_);
state_out.channelMode = channelMode_;
state_out.channelModeExplicit = channelModeExplicit_;
}
{
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
}
state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity
{
// Phase S: persist the voice-system parameters (component state v7).
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
state_out.voiceCount = voiceCount_;
state_out.voiceMode = voiceMode_;
state_out.monoTrigger = monoTrigger_;
}
state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8)
// pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to
// decode + play with no extension present. Filtered (on the snapshot copy, the member is
// untouched) to exactly what the instance currently plays, so the table cannot grow with
// browsing history.
state_out.sampleRefs = sampleRefs();
retainRefs(state_out.sampleRefs,
referencedSampleIds(state_out.selectionId, state_out.map));
// pS-usage: persist the publish identity (v11) so the instance's usage key is
// stable across sessions (records do not proliferate per reopen).
{
std::lock_guard<std::mutex> lock(usageMutex_);
state_out.instanceGuid = instanceGuid_;
}
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
if (!bytes.empty()) {
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
static_cast<int32>(bytes.size()), nullptr);
if (wr != kResultOk) return wr;
}
return kResultOk;
}
std::string ReaSamplerProcessor::selectedSampleId() {
std::lock_guard<std::mutex> lock(selectionMutex_);
return selectedSampleId_;
}
void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
std::lock_guard<std::mutex> lock(selectionMutex_);
selectedSampleId_ = id;
}
PerformanceMap ReaSamplerProcessor::performanceMap() {
std::lock_guard<std::mutex> lock(performanceMutex_);
return performanceMap_;
}
void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
std::lock_guard<std::mutex> lock(performanceMutex_);
performanceMap_ = map;
}
SampleRefs ReaSamplerProcessor::sampleRefs() {
std::lock_guard<std::mutex> lock(refsMutex_);
return sampleRefs_;
}
ChannelMode ReaSamplerProcessor::channelMode() {
std::lock_guard<std::mutex> lock(channelModeMutex_);
return channelMode_;
}
std::uint8_t ReaSamplerProcessor::previewVelocity() {
std::lock_guard<std::mutex> lock(previewMutex_);
return previewVelocity_;
}
void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) {
// Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview
// strike must sound). The editor's knob maps its 0..1 domain into this range before calling.
if (velocity < 1) velocity = 1;
if (velocity > 127) velocity = 127;
std::lock_guard<std::mutex> lock(previewMutex_);
previewVelocity_ = velocity;
}
int ReaSamplerProcessor::voiceCount() {
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
return voiceCount_;
}
void ReaSamplerProcessor::setVoiceCount(int count) {
// Clamp to the shared pure-core range so the engine, the state bytes, and the editor's
// control can never disagree about the legal polyphony span.
if (count < kMinVoiceCount) count = kMinVoiceCount;
if (count > kMaxVoiceCount) count = kMaxVoiceCount;
{
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
if (voiceCount_ == count) return; // no-op: don't churn a rebuild
voiceCount_ = count;
}
// LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from
// the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change
// touches no audio data) and the displaced instrument keeps rendering its ringing tails,
// so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every
// zone from disk. Same contract for the mode/trigger setters below.
rebuildVoiceEngine();
}
VoiceMode ReaSamplerProcessor::voiceMode() {
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
return voiceMode_;
}
void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) {
{
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
if (voiceMode_ == mode) return;
voiceMode_ = mode;
}
rebuildVoiceEngine();
}
MonoTrigger ReaSamplerProcessor::monoTrigger() {
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
return monoTrigger_;
}
void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
{
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
if (monoTrigger_ == trigger) return;
monoTrigger_ = trigger;
}
rebuildVoiceEngine();
}
void ReaSamplerProcessor::setMasterGainLinear(double linear) {
// Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap =
// +24 dB). One relaxed atomic store — the audio thread reads it at the next block start;
// no rebuild, no lock (a post-sum output trim is not a keymap fact).
if (!(linear >= 0.0)) linear = 0.0; // also catches NaN
const double maxLin = masterGainMaxLinear();
if (linear > maxLin) linear = maxLin;
masterGain_.store(static_cast<float>(linear), std::memory_order_relaxed);
}
void ReaSamplerProcessor::previewNoteOn(int note) {
if (note < 0) note = 0;
if (note > 127) note = 127;
const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request
// Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as
// long as we never land back on the exact value the audio thread last consumed in one step —
// 16 bits gives 65535 posts between collisions, unreachable at UI-click rates).
const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_;
const std::uint32_t packed = (static_cast<std::uint32_t>(seq) << 16) |
(static_cast<std::uint32_t>(vel) << 8) |
static_cast<std::uint32_t>(note & 0xFF);
previewOnRequest_.store(packed, std::memory_order_release);
}
void ReaSamplerProcessor::previewNoteOff(int note) {
if (note < 0) note = 0;
if (note > 127) note = 127;
const std::uint16_t seq = ++previewOffSeq_ == 0 ? ++previewOffSeq_ : previewOffSeq_;
const std::uint32_t packed = (static_cast<std::uint32_t>(seq) << 16) |
static_cast<std::uint32_t>(note & 0xFF);
previewOffRequest_.store(packed, std::memory_order_release);
}
void ReaSamplerProcessor::setChannelMode(ChannelMode mode) {
{
std::lock_guard<std::mutex> lock(channelModeMutex_);
// The editor toggle is a DELIBERATE choice either way: latch explicit even on a
// same-mode click (the user confirmed the mode; the GA auto-default stops fighting it).
channelModeExplicit_ = true;
if (channelMode_ == mode) return; // no decode change: don't churn a reload
channelMode_ = mode;
}
// The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no
// restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread
// (mono = downmix, stereo = L/R split) and the RT path just keeps rendering.
reloadInstrument();
}
} // namespace reasampler::vst
+569
View File
@@ -0,0 +1,569 @@
// reasampler_editor.h — the VST3 IPlugView LICE editor for the ReaSampler 9000
// capture-first UI (Phase S10). THIN shell: hosts a LICE-drawn child window inside the
// host's IPlugView seat and routes host paint/mouse into the pure geometry modules
// (capture_browser, keyboard_strip) + the pure mapping (sample_map). Windows-only (D5).
//
// The default face is the CAPTURE BROWSER: a bank-filter tab strip over a grid of
// scannable capture cards (peak thumbnail + name + root/key badge). A fresh instance with
// no pick shows a "pick a capture" EMPTY STATE and plays silence (the S10 policy reversal
// of the S4 first-sample auto-play). Picking a card loads that one capture and reveals a
// guided SINGLE-CAPTURE SETUP surface (a keyboard strip with the capture's root marker +
// a level readout). Multi-zone keymap editing is a demoted, opt-in ZONES panel (S10-Z),
// reached by a toggle and driven by the same keyboard_strip drag machine.
//
// All layout/hit-test/drag math lives in the pure modules; this shell only draws + routes
// (a LICE_SysBitmap blitted in WM_PAINT, a WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP
// drag-state machine hit-testing via the pure resolvers). Peak thumbnails are computed
// shell-side from the decoded WAV (bank_model's Sample carries no envelope) and cached —
// the mirror of bank_panel::thumbnailFor. Every edit commits OFF the audio thread via the
// processor's reloadInstrument (RT path untouched).
//
// Subclasses CPluginView for the IPlugView boilerplate; overrides the attach/remove hooks
// to create/destroy the child window and onSize to resize it.
#pragma once
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
#include "public.sdk/source/common/pluginview.h"
#include "core/instrument/ui/editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules)
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit)
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (r11 knob deck — Sample FB1, Zone FB2)
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state)
#ifdef _WIN32
#include <windows.h>
#endif
class LICE_IBitmap; // fwd: the paint helpers take one; lice.h is included only in the .cpp
namespace reasampler::vst {
// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim
// is retired from the editor family; engine symbols — ChannelMode, VoiceMode, MonoTrigger,
// the voice-count constants, VelocityCurve via the engine re-export — stay in flat
// `reasampler` and resolve via the enclosing namespace).
using audio::AudioSample;
using audio::Envelope;
using instrument::map::BankChoice;
using instrument::map::PerformanceMap;
using instrument::map::PerformanceZone;
using instrument::map::SampleChoice;
using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs;
using instrument::map::ZonePlaySeconds;
using instrument::ui::AmpEnvelope;
using instrument::ui::DeckGroupDesc;
using instrument::ui::EnvClampBounds;
using instrument::ui::EnvNode;
using instrument::ui::Rect;
class ReaSamplerProcessor;
class ReaSamplerEditor : public Steinberg::CPluginView {
public:
// `processor` owns this editor's lifetime domain and outlives it; the editor reads the
// live bank through it and drives selection/zone edits + reload on user input. May be
// null (defensive — a real host always supplies one).
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
~ReaSamplerEditor() override;
Steinberg::tresult PLUGIN_API isPlatformTypeSupported(
Steinberg::FIDString type) override;
Steinberg::tresult PLUGIN_API canResize() override;
Steinberg::tresult PLUGIN_API checkSizeConstraint(Steinberg::ViewRect* rect) override;
protected:
void attachedToParent() override;
void removedFromParent() override;
Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;
private:
// Which face the editor shows (S-VIEW-1, three-view model). Sample is the HOME/default
// face (the loaded capture). Browse is a full-window MODAL picker overlaid on Sample
// (select + confirm/cancel changes the loaded capture, then dismisses). Zone is the
// dedicated multi-zone keymap surface, button-summoned. All three draw over the same
// snapshotted bank; Browse + Zone return to Sample when dismissed.
enum class View { kSample, kBrowse, kZone };
// What a mouse drag is currently editing (the drag-state machine). kNone = no drag in
// flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the
// single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop
// marker on the S11 waveform surface (which marker is in waveMarker_); kEnvNode is a
// draggable envelope breakpoint on the Sample-view hero overlay (S-VIEW-3, which node in
// envNode_); kCurveNode is a draggable velocity-curve control point in the S-VIEW-10
// transfer-curve editor (which point in curvePointIndex_); kDeckKnob is a GRAB-ANCHORED
// vertical radial-knob drag on an r11 knob deck — the Sample face's deck/cluster (FB1)
// or the Zone panel's per-zone deck (FB2) — (which control in dragParamId_; the value at
// grab in dragKnobStartValue_ — no jump on grab, FA4).
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
kScrollThumb, kEnvNode, kCurveNode, kDeckKnob };
// The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces).
// The int value is the opaque control id the pure knob_deck hit-test returns; the shell
// maps it to the picked zone's play params (or a processor-side per-instance setter).
enum class ParamControl {
kPlayMode = 0, // Gate | Trigger toggle (S15)
kPitchEngine, // Varispeed | Preserve toggle (S16)
kAttack, // AHDSR attack (Gate) / —
kHold, // AHDSR hold (Gate, S15)
kDecay, // AHDSR decay (Gate)
kSustain, // AHDSR sustain (Gate)
kRelease, // AHDSR release (Gate)
kTrigLength, // Trigger %-length (Trigger, S15)
kTrigFadeIn, // Trigger fade-in (Trigger, S15)
kTrigFadeOut, // Trigger fade-out (Trigger, S15)
kPitchEnvEnable, // AD pitch envelope on|off (S16)
kPitchEnvAttack, // AD pitch attack (S16)
kPitchEnvDecay, // AD pitch decay (S16)
kPitchEnvDepth, // AD pitch depth in +/- semitones (S16)
kKeyTrack, // S-VIEW-6 key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds)
// r11 deck-only controls (FB1): processor-side per-instance params, NOT zone params —
// routed to the processor setters, never through applyZoneControl / the map.
kVoiceCount, // Phase S polyphony bound (1..32) — a stepped knob in the VOICE group
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
kMasterGain, // FB1 post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
kCount
};
// The waveform markers on the single-capture setup surface (S11). Order is the draw + hit
// order (start first). Named generically per the spec so S15 can repurpose the surface with
// a different marker set; here it is start-point + the sustain loop's two ends.
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 };
// --- Hover model (Phase L, L3) ------------------------------------------------
//
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE so the kit
// draws its hover state on that element only ("hover on every interactive element" +
// "sub-frame feedback = the perception of speed", §3.3/§3.5). Cleared to kNone on
// WM_MOUSELEAVE (tracked via TrackMouseEvent). `index` disambiguates within a kind
// (tab ordinal, visible-card index, control-row id); -1 when not applicable. Mirror of
// bank_panel's L2 hover model.
enum class HoverKind {
kNone,
kNavBrowse, // the Sample-view "Browse" title-band button (opens the Browse modal)
kNavZone, // the Sample-view "Zone" title-band button (opens the Zone surface)
kBack, // the Browse/Zone "back" affordance (returns to Sample)
kSearchBox, // the browser search box
kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All)
kCard, // a capture card (index = visible_ index)
kBrowseConfirm, // the Browse modal "Load" confirm button
kBrowseCancel, // the Browse modal "Cancel" button
kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment
kPreview, // the Sample-view preview-trigger button
kAddZone, // the "+ Add Zone" button
kDeleteZone, // the "Delete" zone button
kControl, // a knob-deck element (index = control id)
kCurveNode, // a velocity-curve control point (index = point index, S-VIEW-10)
kVelKnob, // the cluster preview-velocity radial knob (r11)
kCurveButton, // the cluster mini curve-preview button (r11 — opens the popup)
kPopupClose, // the curve popup's Close (x) button (r11)
};
struct HoverTarget {
HoverKind kind = HoverKind::kNone;
int index = -1;
bool operator==(const HoverTarget& o) const { return kind == o.kind && index == o.index; }
bool operator!=(const HoverTarget& o) const { return !(*this == o); }
};
#ifdef _WIN32
void paint(HDC hdc);
void paintSample(LICE_IBitmap* bmp, int w, int h); // S-VIEW-2/r11 home face
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // S-VIEW-5 modal picker overlay
void paintZone(LICE_IBitmap* bmp, int w, int h); // S-VIEW-8 zone surface
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
// --- r11 knob-deck rendering (FB1 Sample face; FB2 Zone panel) -------------------
// The knob deck: the fenced task groups drawn through the L1 kit — group fence + caption +
// compact caption toggles + radial knobs (param_slider's FA4 primitive) with label<->value
// swap on hover/drag. `descs` picks the group set: the full Sample deck (deckGroupDescs)
// or the Zone panel's per-zone groups (zoneDeckGroupDescs). Lays out from deckArea's
// top-left; the caller anchors (Sample bottom-anchors, Zone top-anchors).
void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone,
const std::vector<DeckGroupDesc>& descs);
// The mini curve-preview button (shared by the Sample cluster + the Zone panel, FB2): a
// hairline bg/cell square tracing the zone's live curve; Active border while the popup is up.
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
// The centered curve-popup sheet (wash + title + close + full-size curve editor). Edits
// popupZone() — the Sample face's one-zone site or the Zone surface's selected zone (FB2).
void paintCurvePopup(LICE_IBitmap* bmp, int w, int h);
// Trace the S-VIEW-3 amp-envelope overlay + its draggable node handles over `waveArea` for
// `zone`'s play params, at the sample's wall-clock duration. Shared by the Sample hero band.
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone,
std::int64_t frames);
// S-VIEW-10: the velocity->amp transfer-curve editor — a bordered box (X = velocity 0-127,
// Y = amp 0-1), the monotone spline traced by eval, one draggable node handle per control
// point. Since FB2 its ONLY host is the r11 popup sheet (both surfaces summon it via the
// mini preview button); all mapping / hit-test / clamp math lives in the pure
// velocity_curve module. `r` empty -> draws nothing.
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
// Route a mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab
// starts a kCurveNode drag; Alt-click on an interior node deletes it (committed at once);
// an empty-space click ADDS a point at the cursor and grabs it for an immediate drag.
// `zoneIndex` must be a valid index into map_.zones (callers materialize first).
void handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y);
// Route a left-click while the curve popup is open (the popup is MODAL over the Sample
// face AND the Zone surface, FB2): Close / outside-wash dismiss, in-box clicks into the
// shared curve machinery against popupZoneIndex(), everything else on the sheet swallowed.
// Returns true when the popup consumed the click (i.e. whenever it is open).
bool handlePopupMouseDown(int w, int h, int x, int y);
void onMouseDown(int x, int y);
// The Browse-modal and Zone-surface halves of the mouse-down dispatch (Q-W2v: the
// input TUs split along the face axis — onMouseDown keeps the Sample-face branch and
// delegates these two; bodies in editor_input_browse_zone.cpp). Behavior-identical
// to the former inline branches.
void mouseDownBrowse(int w, int h, int x, int y);
void mouseDownZone(int w, int h, int x, int y);
void onMouseMove(int x, int y);
void onMouseUp(int x, int y);
// r11: right-click — the curve popup's PRIMARY node-delete affordance (issue 3c). Only
// acts while the popup is open (over the Sample face OR the Zone surface, FB2); a
// right-click on a popup curve node deletes it through the same commit path as Alt-click
// (deletePoint's endpoint guard makes endpoint right-clicks a safe no-op). Everything
// else ignores right-clicks.
void onMouseRDown(int x, int y);
// Apply a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: routes ordinary
// controls through applyControl against the zone's play struct, and kKeyTrack against the
// zone's keyTrack scalar (0..200% over the knob's 0..1). Used by both the click + drag paths.
void applyZoneControl(int zoneIndex, int id, double value, int segment);
// Resolve the interactive element under (x, y) into hover_ (Phase L, L3). Called from
// WM_MOUSEMOVE (also while a drag is in flight — the resolved element just isn't used
// for a hover repaint mid-drag). Repaints only when the hovered element changed, so an
// idle mouse-move is free. Windows-only (the hit-tests use the shell's Win32 client rect).
void resolveHover(int x, int y);
// True iff element (kind, index) is the live hover_ target — the shell maps this to the
// kit's Hover interaction state when the element has no more-specific state (Active, etc.).
bool isHovered(HoverKind kind, int index) const {
return hover_.kind == kind && hover_.index == index;
}
void onMouseWheel(int delta); // S12 browser scroll (wheel)
void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke
// S13 (relay degraded): an OS file drop landed on the editor window. We do NOT ingest (the
// instrument is a read-only bank consumer and the relay is unshipped) — we flash the "drop
// on the ReaSampler panel to add" affordance so the drop is never silently swallowed and the
// shipped ingest gesture stays discoverable. `droppedCount` is how many files were dropped
// (drawn into the banner). NEVER inserts a timeline item / never touches the bank.
void onFilesDropped(int droppedCount);
// The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the
// audio thread). Polls the processor's bank-sync (generation change -> hands-free reload;
// a new assignment request -> apply as this instance's selection) and, when anything
// changed, re-snapshots the editor's own view (refreshFromBank) + repaints so the browser /
// setup surface reflect the new bank. An open editor means THIS instance is the focused
// assignment target (the thundering-herd policy — see the handoff), so it passes true.
// Suppressed WHILE A DRAG IS IN FLIGHT so a mid-drag reload does not yank the edit surface.
void onSyncTimer();
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
void invalidate();
HWND childHwnd_ = nullptr;
#endif
// Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's
// selection + performance map. Main/UI thread only. Called on attach and after any edit.
void refreshFromBank();
// Publish the edited zones/selection to the processor, then rebuild the instrument OFF
// the audio thread. UI thread only. One place so every edit commits identically.
void commitAndReload();
// Commit `id` as the loaded single-capture selection (the Browse Load confirm and the
// double-click accelerator both route here). Runs reconcileSingleCaptureZones first so
// the previous sample's materialized full-range zone cannot linger and shadow the new
// pick under first-match resolve (the zone-bleed fix, issue 3a), then publishes + reloads.
void loadSelection(const std::string& id);
// Recompute the capture cards visible under the current bank filter (samples_ narrowed by
// activeFilterBankId_; "" = All) into visible_. Called on refresh + filter change.
void rebuildVisible();
// The peak thumbnail for a bank sample id at `binCount` bins, computed once from the
// decoded WAV (mirror of bank_panel::thumbnailFor) and cached by (id, binCount). Returns
// an empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O).
const Envelope& thumbnailFor(const std::string& sampleId, int binCount);
// The decoded MONO PCM for a bank sample id, decoded once from the WAV and cached by id.
// Feeds the S11 waveform surface: the full-res envelope binned at view width AND the
// zero-crossing snap (both need the raw frames, not the binned thumbnail). Returns an empty
// vector when the WAV can't be resolved/decoded. UI thread only (file I/O). Reuses the same
// decode path as thumbnailFor (no new WAV reader), keyed by id (not width — snap is width-
// independent). Cleared with the thumbnail cache on refresh.
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
// The effective loop + start markers for the picked single capture (S11): the per-zone
// OVERRIDE for the picked id when one exists in map_, else the bank's S2 loop intrinsic
// (loop) / frame 0 (start). Absent loop -> loopStart==loopEnd==0 (the "no loop" state).
// frames is the decoded length (for defaulting loopEnd when the bank left the loop empty).
struct SetupMarkers {
std::int64_t start = 0;
std::int64_t loopStart = 0;
std::int64_t loopEnd = 0;
bool hasLoop = false; // whether a sustain loop is set (drives the "no loop" affordance)
};
SetupMarkers pickedMarkers(std::int64_t frames) const;
// Commit an edited marker set for the picked capture as a per-zone loop/start override
// (upsert on the picked id — mirror of the root-marker path), then reload off-thread.
void commitPickedMarkers(const SetupMarkers& m);
// Write `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append).
// Does NOT call commitAndReload — callers decide whether this is a live-drag update or a
// final commit. selectedId_ must be non-empty before calling. Returns the zone index
// (0-based) that was updated or appended, so callers can set selectedZone_.
int upsertPickedOverride(const SetupMarkers& m);
// --- S12/S15/S16 parameter value domains (both deck surfaces) ------------------
//
// The deck knobs edit a zone's ZonePlaySeconds (S15 play mode + AHDSR; S16 pitch engine +
// AD pitch envelope). Wall-clock times are SECONDS (rate-free); the keymap build resolves
// them to frames at the live rate. Instrument-owned (D-B), never a bank fact.
// The normalized [0,1] display value for control `id` given `play` (the shell's domain
// mapping: seconds->0..1 over a fixed seconds ceiling, sustain 0..1 as-is, %-length/fade
// frames->0..1, semitone depth centered at 0.5).
double controlValue(int id, const ZonePlaySeconds& play) const;
// Apply a committed control interaction to `play`: a knob's normalized `value` (mapped back
// into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place.
void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
// The Trigger fade-in/out knob full-scale, in SOURCE frames: kFadeMaxSeconds (2 s
// wall-clock) resolved against the live rate at use (Q-W0 T3-03 — never a baked-in
// rate). 44.1 kHz fallback before setupProcessing has run. Storage stays frames.
double fadeMaxFrames() const;
// --- S-VIEW-3 envelope overlay seam (frames <-> fraction converter) ----------
//
// envelope_overlay's AmpEnvelope is a DERIVED VIEW, not a TriggerParams copy: it stores the
// Trigger fades as FRACTIONS of the played span, while the zone stores them as SOURCE FRAMES.
// These two members own the non-trivial conversion on BOTH paths (documented in
// envelope_overlay.h's TRIGGER SEAM note). `frames` is the sample's total source frame count;
// `rate` is the live sample rate (the wall-clock AHDSR seconds are rate-free and copy 1-to-1,
// but the Trigger played-span math needs the frame count).
// PACK (draw): zone play params -> AmpEnvelope. Copies AHDSR seconds directly; derives the
// Trigger fade fractions from the source-frame fades over the played span.
// `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)).
AmpEnvelope packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const;
// UNPACK (commit): an edited AmpEnvelope -> the zone's play params. Copies AHDSR seconds
// directly; converts the Trigger fade fractions back to source frames over the played span.
// `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)).
// Mutates `play` in place; only the mode-relevant fields are written.
void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame,
ZonePlaySeconds& play) const;
// The clamp bounds envelope_edit uses, matching the control-panel sliders' own domains (so a
// node drag can never produce a param a slider couldn't — the S-VIEW-F2 invariant).
EnvClampBounds envClampBounds() const;
// --- Sample-view resolution helpers (the ONE storage site, S15-F2) -----------
//
// The single-capture Sample face reads/writes the same one-zone map site as the Zone surface.
// These resolve the effective values for the picked id: effectiveSampleZone returns the picked
// id's one-zone override (found in map_) or a product-default PerformanceZone bound to the
// picked id (not yet materialized — a control edit materializes it, mirroring the Zone path).
PerformanceZone effectiveSampleZone() const;
// The effective root: the picked id's rootOverride, else its bank intrinsic, else middle C.
int effectiveRoot() const;
// The live sample rate from the bridge (for the envelope overlay's seconds<->frames time base),
// or 0 when unavailable (the caller guards). Matches the voice engine's resolution rate.
double liveSampleRate() const;
// The persisted preview velocity as a 0..1 slider value (MIDI 1..127 mapped onto [0,1]).
double previewVelocity01() const;
// Find-or-materialize the one-zone override for the picked id and return a mutable index into
// map_.zones (appending a product-default zone if none exists). selectedId_ must be non-empty.
// The mirror of upsertPickedOverride for a control edit — used when a Sample-face control edit
// needs a concrete zone to write. Returns -1 if selectedId_ is empty.
int ensureSampleZone();
// --- Curve-popup target resolution (r11 FB1 + FB2) -----------------------------
//
// The popup edits ONE zone per open: the Zone surface's SELECTED zone (FB2) or the Sample
// face's picked one-zone site. popupZone is the read-only resolve (paint/hover/right-click
// hit-test); popupZoneIndex is the edit target — it materializes the Sample-face zone via
// ensureSampleZone but NEVER materializes on the Zone surface (the button only shows for
// an explicit selection). Returns -1 when there is no valid target (callers guard).
PerformanceZone popupZone() const;
int popupZoneIndex();
// --- r11 knob-deck plumbing (FB1 Sample face; FB2 Zone panel) -------------------
//
// The deck is the r11 replacement for the slider control strips on BOTH surfaces: the pure
// knob_deck module lays out the fenced groups, param_slider's FA4 primitive owns the
// value<->needle map, and these members own the control-id <-> value binding.
// The PER-ZONE deck groups (FB2 — the set both surfaces share): AMP ENVELOPE (Gate:
// A/H/D/S/R; Trigger: Fade In / Length % / Fade Out + two RESERVED blanks so a mode flip
// never reflows the neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth).
// The Zone panel renders exactly these — per-instance state stays off it.
std::vector<DeckGroupDesc> zoneDeckGroupDescs(const ZonePlaySeconds& play) const;
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE (Voices
// knob + Poly|Mono caption toggle + Retrig|Legato row toggle) and MASTER (the FB1
// post-mixer Gain knob) groups.
std::vector<DeckGroupDesc> deckGroupDescs(const ZonePlaySeconds& play) const;
// The normalized [0,1] value a deck knob shows for `zone` — zone params route through
// controlValue/keyTrack; the processor-side ids (voice count, master gain, and the
// cluster's preview velocity via the -2 sentinel) read the processor's live value, so
// the knob and its storage are two views on one model (re-read each paint).
double deckControlNorm(int id, const PerformanceZone& zone) const;
// Apply a deck-knob value: zone params write map_.zones[zoneIndex] (live-drag semantics,
// commit on release); processor params (voice count / master gain / preview velocity)
// write through the processor setters immediately (transient — no map edit, no reload).
// zoneIndex is ignored for processor-side ids.
void applyDeckKnob(int zoneIndex, int id, double norm);
// The knob's live value label (shown in place of the name label during hover/drag):
// seconds ("0.123s"), percents ("85%"), source frames ("8820f"), signed semitones
// ("+3.5st"), a voice count ("16"), or the master-gain dB ("-inf"/"+2.4dB").
std::string deckValueLabel(int id, const PerformanceZone& zone) const;
ReaSamplerProcessor* processor_ = nullptr;
// --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) ---
std::vector<SampleChoice> samples_; // every bank sample, bank order
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
std::string selectedId_; // the single-capture pick ("" = empty state)
PerformanceMap map_; // the opt-in zones (empty = no zones)
ChannelMode channelMode_ = ChannelMode::Mono; // S7 mono/stereo toggle snapshot
// --- Phase S voice-deck snapshot (PROVISIONAL controls — the Wave B recompose owns the
// final deck). Mirrors of the processor's persisted voice-system params, refreshed with
// the rest of the live snapshot; every edit writes through the processor setters (which
// rebuild the engine off-thread via the drain-slot swap).
int voiceCount_ = kDefaultVoiceCount;
VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
// --- Transient UI state (not persisted; component state carries selection + zones) ---
View view_ = View::kSample; // default face is the loaded-sample home (S-VIEW-1)
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
int selectedZone_ = -1; // highlighted zone in the Zone surface; -1 = none
// --- S-VIEW-5 Browse modal picker (a selection PENDING confirm) ---------------
// The Browse overlay is a select-then-confirm picker: a click marks a pending pick without
// loading it; Confirm (or double-click) commits it to selectedId_ + reloads and returns to
// Sample; Cancel discards it and returns to Sample unchanged. "" = nothing picked yet.
std::string browsePendingId_;
int lastBrowseClickCard_ = -1; // for double-click-to-load detection (visible_ index)
// --- S-VIEW-4 preview-trigger note (transient) -------------------------------
// The MIDI note the preview button is currently sounding (a held Gate voice), or -1 when the
// button is up. Set on preview-button press (note-on posted to the processor), cleared on
// release (note-off posted). One note at a time — a fresh press releases the prior.
int previewingNote_ = -1;
// --- S13 drop-to-load affordance (relay DEGRADED — transient, never persisted) ----
// S13's cross-artifact ingest relay (editor drop -> extension ingest) is NOT shipped: the
// instrument's REAPER bridge is deliberately READ-ONLY (it never writes the bank / ext
// state), so an editor drop cannot relay a bank-ingest request without a new write seam +
// an extension-side poller (surfaced as a decision, not crossed here). The DEGRADE path per
// the spec: the editor ACCEPTS the drop (WM_DROPFILES) and, rather than silently swallowing
// it, flashes a clear affordance pointing at the shipped ingest gesture (drop onto the
// docked ReaSampler panel). When > 0, the affordance banner is shown; each sync tick decays
// it so it auto-dismisses. No file is ingested, no timeline item is ever inserted.
int dropHintTicks_ = 0; // remaining sync ticks to show the drop affordance
// --- S12 browser scroll + search (transient UI state, never persisted) --------
int scrollOffset_ = 0; // vertical px offset into the card grid (clamped)
std::string searchQuery_; // type-to-filter narrow; "" = no search
bool searchFocused_ = false; // whether the search box has keyboard focus
// --- S12 numeric note entry (LICE text-entry idiom, transient) ----------------
// When >= 0, a low/high/root field is being typed; entryText_ accumulates the keystrokes
// and commits (parseNoteEntry) on Enter. -1 = no field editing. The field id is a
// ParamControl-independent small enum encoded inline (see the .cpp: 0=low,1=high,2=root).
int entryField_ = -1;
std::string entryText_;
// --- Hover state (Phase L, L3; transient, never persisted) --------------------
HoverTarget hover_; // the interactive element under the pointer
#ifdef _WIN32
bool mouseTracking_ = false; // TrackMouseEvent armed for WM_MOUSELEAVE this "over" cycle
#endif
// --- Drag-state machine ------------------------------------------------------
DragKind drag_ = DragKind::kNone;
int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver
int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag
int dragCurX_ = 0; // live cursor x (px) during a drag — updated in onMouseMove
int dragCurY_ = 0; // live cursor y (px) during a drag — updated in onMouseMove
int dragStartLow_ = 0; // the grabbed field's note at grab time
int dragStartHigh_ = 0;
int dragStartRoot_ = 60;
PerformanceMap dragStartMap_; // map_ snapshotted at grab; restored on capture-loss
// S11 waveform-marker drag: which marker + the marker set snapshotted at grab time (so the
// pixel-delta resolver shifts the grabbed frame from its grab-time value, and inter-marker
// clamps use the sibling markers).
WaveMarker waveMarker_ = WaveMarker::kStart;
SetupMarkers dragStartMarkers_;
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
std::int64_t dragStartFrame_ = 0; // zone startPoint at grab time (0 if absent); for env-node drag
// S12 scrollbar-thumb drag: the offset held at grab time (the pixel-delta resolver shifts
// from it). kDeckKnob drag: which control id + the zone it edits.
int dragStartScrollOffset_ = 0;
int dragParamId_ = -1; // control id under a kDeckKnob drag; -2 = preview-vel knob
int dragParamZone_ = -1; // the zone index a kDeckKnob drag edits; -1 = processor-side
// S-VIEW-3 envelope-node drag: which node is grabbed + the AmpEnvelope snapshotted at grab
// (so the pixel delta is absolute, per envelope_edit's grabEnv contract). The overlay rect +
// sample frame count are re-derived at move time from the live Sample-view layout.
EnvNode envNode_ = EnvNode::Origin;
AmpEnvelope dragStartEnv_{};
// S-VIEW-10 velocity-curve node drag: which point is grabbed, the curve snapshotted at grab
// (resolvePointDrag's absolute-delta contract), the box rect the grab happened in (the Sample
// and Zone views place the editor differently — the drag resolves against the grab-time box),
// and which zone the edit lands on. Mirror of the envelope-node drag state.
int curvePointIndex_ = -1;
VelocityCurve dragStartCurve_ = VelocityCurve::flat();
Rect dragCurveRect_{};
int dragCurveZone_ = -1;
// r11 deck-knob drag (FB1): the control's normalized value AT GRAB — knobDragValue maps
// the vertical pixel delta from this anchor, so a grab never jumps the value (FA4).
double dragKnobStartValue_ = 0.0;
// r11 curve popup (FB1 + FB2): open flag — editor-local, never persisted. The popup edits
// popupZone() — the picked capture's one-zone site on the Sample face, the SELECTED zone
// on the Zone surface — re-resolved each paint so a sync-tick refresh mid-open stays
// coherent (a refresh that drops the target closes it; see refreshFromBank).
bool curvePopupOpen_ = false;
// --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------
// Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so
// a bank edit (a re-captured or deleted sample) does not show a stale thumbnail.
std::unordered_map<std::string, Envelope> thumbCache_;
// --- Decoded mono-PCM cache (S11; id -> full-res frames) ------------------------------
// Keyed by id (width-independent, unlike thumbCache_). Feeds the waveform envelope binning
// + the zero-crossing snap. Cleared alongside thumbCache_ on refresh so a re-captured or
// deleted sample does not show/snap against stale PCM.
std::unordered_map<std::string, std::vector<AudioSample>> pcmCache_;
};
} // namespace reasampler::vst
+1 -1
View File
@@ -16,7 +16,7 @@
#include "core/instrument/ui/embed_strip.h" // the pure strip layout + hit-test
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey
#include "shell/instrument/reaper_bridge.h"
#include "reasampler_processor.h"
#include "shell/instrument/reasampler_processor.h"
#include "core/ui/theme.h" // Role / InteractionState / spectralColor (L3)
// wdltypes.h first: it defines INT_PTR portably (and pulls <windows.h> on Windows), which
@@ -0,0 +1,425 @@
// reasampler_processor.cpp — see reasampler_processor.h. Since Q-W2v (T4-12) this TU is
// the VST3 LIFECYCLE + the REAL-TIME process() path ONLY: factory/queryInterface,
// initialize/terminate/setActive, bus setup, and the block render (MIDI marshal, preview
// mailbox drain, engine + drain sum, master-gain ramp). Component-state I/O + parameter
// accessors live in processor_state.cpp; the off-thread reload/publish family lives in
// processor_reload.cpp. process() and its per-block work stay ONE TU (T4-29): no virtual
// seam, no cross-TU call on the per-sample path.
#include "shell/instrument/reasampler_processor.h"
#include <cstdint>
#include <memory>
#include <mutex>
#include "pluginterfaces/vst/ivstaudioprocessor.h"
#include "pluginterfaces/vst/ivstevents.h"
#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic)
#include "pluginterfaces/vst/vstspeaker.h"
#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor
#include "shell/instrument/reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
using namespace Steinberg;
using namespace Steinberg::Vst;
namespace reasampler::vst {
namespace {
// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a
// linear per-sample step derived from this at setupProcessing (gainRampStep_ =
// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing
// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as
// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is
// ~20 ms at EVERY host rate; the snap threshold (half a step, below which gainCurrent_
// jumps to the target) avoids long sub-LSB creep and the ramp loop on idle blocks.
constexpr double kGainRampSeconds = 0.020;
} // namespace
FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
// The host owns the returned reference. Cast up to the combined interface the SDK
// exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
return static_cast<IAudioProcessor*>(new ReaSamplerProcessor());
}
// Out-of-line so unique_ptr<ReaSamplerEmbed> sees the complete type here.
ReaSamplerProcessor::~ReaSamplerProcessor() = default;
tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) {
// S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for
// IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed
// shell. We own the shell (unique_ptr); the borrowed reference is valid because the
// processor outlives it. All other iids fall through to the SDK's queryInterface.
if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) {
if (!embed_) embed_ = std::make_unique<ReaSamplerEmbed>(this);
embed_->addRef();
*obj = static_cast<IReaperUIEmbedInterface*>(embed_.get());
return kResultOk;
}
return SingleComponentEffect::queryInterface(iid, obj);
}
tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
tresult result = SingleComponentEffect::initialize(context);
if (result != kResultOk) return result;
// Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the
// instrument still loads, it just has no live bank to play.
bridge_.connect(context);
// Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no
// audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of
// the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders
// dual-mono through the stereo bus (both channels equal, centered), which is audibly
// identical to a mono bus but never asks the host to re-map a live instance's pins. The
// prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every
// mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The
// in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer
// write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's
// re-routing of the live instance's pins across the arrangement change. A fixed arrangement
// is the maximally-standard VSTi shape and removes that whole negotiation surface.
addEventInput(STR16("MIDI In"), 16);
addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo);
return kResultOk;
}
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
// process() is not running at terminate: free the live + draining instruments and
// drain the graveyard. Take the pointers out of the atomics first so nothing else
// races them.
std::lock_guard<std::mutex> lock(reloadMutex_);
delete live_.exchange(nullptr);
delete draining_.exchange(nullptr);
graveyard_.clear();
return SingleComponentEffect::terminate();
}
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
// Activating: build the instrument from the currently-selected sample so the first
// block after activation can play. Deactivating: process is now GUARANTEED stopped by
// the host, so this is the safe point to reclaim the graveyard (the displaced engines
// no reload could free while active). The build/drain are off the audio thread —
// setActive is a main/UI-thread call.
if (state) {
// Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED
// sample refs — it needs no bank read, so it plays regardless of whether the
// extension's PROJEXTSTATE has parsed yet (or the extension exists at all).
//
// #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a
// pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic
// refreshRefsFromBank copies the refs in when the bank blob is readable by
// activation time, so an upgraded project plays on load without the instrument
// ever being opened (and the next save is self-contained). Residual load-order
// race, DAW-verifiable only: if the host activates this instance BEFORE the
// project's ext-state lines parse, the lift misses here and — with no editor open —
// nothing retries until the next activation or editor tick. MIGRATION NOTE: open a
// pre-v10 instrument once after upgrading if it restores silent.
reloadInstrument();
} else {
std::lock_guard<std::mutex> lock(reloadMutex_);
// process is guaranteed stopped: free EVERYTHING. The live instrument too — its
// voices are frozen mid-flight, and if it survived deactivation the reactivate
// reload would displace it into the DRAIN slot, resurrecting stale sustained
// voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above),
// so nothing is lost by clearing here.
delete live_.exchange(nullptr);
delete draining_.exchange(nullptr);
graveyard_.clear();
}
return kResultOk;
}
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
sampleRate_ = setup.sampleRate;
maxBlockSize_ = setup.maxSamplesPerBlock;
// T3-01: resolve the FB1 gain-ramp step against the live host rate (20 ms wall-clock at
// every rate). At 48 kHz this is exactly the former 1/960 constant. Written here (host
// guarantees setupProcessing never overlaps process), read on the audio thread only.
if (sampleRate_ > 0.0) {
gainRampStep_ = static_cast<float>(1.0 / (kGainRampSeconds * sampleRate_));
}
return SingleComponentEffect::setupProcessing(setup);
}
tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
SpeakerArrangement* inputs, int32 numIns,
SpeakerArrangement* outputs, int32 numOuts) {
// ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a
// decode policy, never a bus fact). We take NO audio input, so any inputs are rejected.
// Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse)
// and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a
// proposal keeps a valid arrangement of its own) — the host adapts its routing to us.
if (numIns < 0 || numOuts < 0) return kInvalidArgument;
if (numIns > 0) return kResultFalse; // no audio input bus to arrange
if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue;
return kResultFalse;
}
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
// REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments
// once for the whole block (two atomic acquires), then publish the MINIMUM installedAt
// over the pointers held so the off-thread graveyard pruner knows exactly which
// generations this block is holding (see the header proof).
//
// We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an
// ordering race: reading reloadGeneration_ after the slots could observe a generation
// newer than the pointers we actually hold, causing the pruner to free an instrument
// process is still reading. installedAt was set on the reload path before the atomic
// exchange that made the instrument visible.
//
// The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the
// last reload: its already-sounding voices keep rendering (and receive note-offs) so a
// curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons.
// A racing reload can briefly leave the same pointer in both slots (live_ was loaded
// before the swap, draining_ after); collapse that to live-only so one engine is never
// advanced twice per frame.
LoadedInstrument* inst = live_.load(std::memory_order_acquire);
LoadedInstrument* drain = draining_.load(std::memory_order_acquire);
if (drain == inst) drain = nullptr;
std::uint64_t heldGen = 0;
if (inst && drain) {
heldGen = inst->installedAt < drain->installedAt ? inst->installedAt
: drain->installedAt;
} else if (inst) {
heldGen = inst->installedAt;
} else if (drain) {
heldGen = drain->installedAt;
}
processGeneration_.store(heldGen, std::memory_order_release);
// Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine
// voice silent) by naming its OWN installedAt (0 = no drain / still
// sounding). Evaluated at block START — idleness is monotone for a drain (it receives no
// note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply
// publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe.
drainIdleGeneration_.store(
(drain && drain->fullyIdle()) ? drain->installedAt : 0,
std::memory_order_relaxed);
// Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps
// events at block granularity (no per-event sample-offset split) — audible timing is
// within one block, adequate for Tier 0; sample-accurate scheduling is a later tier.
// Note-offs also route to the DRAIN engine so a note held across a reload releases
// its old-snapshot voice too (otherwise it would sustain until the next reload).
if (data.inputEvents) {
const int32 count = data.inputEvents->getEventCount();
for (int32 i = 0; i < count; ++i) {
Event e;
if (data.inputEvents->getEvent(i, e) != kResultOk) continue;
if (e.type == Event::kNoteOnEvent) {
// A note-on with velocity 0 is a note-off by MIDI convention.
const int vel = static_cast<int>(e.noteOn.velocity * 127.0f + 0.5f);
if (vel <= 0) {
if (inst) inst->engine.noteOff(e.noteOn.pitch);
if (drain) drain->engine.noteOff(e.noteOn.pitch);
} else if (inst) {
inst->engine.noteOn(e.noteOn.pitch, vel);
}
} else if (e.type == Event::kNoteOffEvent) {
if (inst) inst->engine.noteOff(e.noteOff.pitch);
if (drain) drain->engine.noteOff(e.noteOff.pitch);
} else if (e.type == Event::kLegacyMIDICCOutEvent) {
// PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a
// VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism
// — the type is nominally an output event; DAW-verify, see handoff).
// CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR
// release tail; Trigger one-shots play through their bounded play length.
// CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless
// of play mode, including Trigger one-shots that ignore CC 123. This is the
// true "panic" for a ringing one-shot (e.g. a full-length capture).
// Both clear the mono held stack. Both apply to live AND drain. A ringing
// preview note is a real engine voice since the PreviewCard retirement, so
// the panics cover it with no separate routing. allNotesOff / allSoundsOff
// are RT-safe (no allocation, bounded scans).
const auto cc = static_cast<int>(e.midiCCOut.controlNumber);
if (cc == kCtrlAllSoundsOff) {
if (inst) inst->engine.allSoundsOff();
if (drain) drain->engine.allSoundsOff();
} else if (cc == kCtrlAllNotesOff) {
if (inst) inst->engine.allNotesOff();
if (drain) drain->engine.allNotesOff();
}
}
}
}
// S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed
// atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last
// one we consumed; fire it once, then latch the sequence so the same request never re-fires.
// Preview redesign: the drained requests drive the MAIN VoiceEngine — the exact
// noteOn/noteOff calls the host MIDI marshal above makes — so a preview note is a real
// voice: it counts against the voice count, can steal / be stolen, and respects
// Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's
// isolation). The editor posts the root note, so it plays at unity.
// Consume (advance the sequence) even when inst is null so a note-on posted while no instrument
// is loaded does not re-fire stale on the next instrument load.
{
const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire);
const std::uint16_t onSeq = static_cast<std::uint16_t>(on >> 16);
if (onSeq != 0 && onSeq != previewOnConsumed_) {
previewOnConsumed_ = onSeq;
if (inst) {
const int vel = static_cast<int>((on >> 8) & 0xFF);
const int note = static_cast<int>(on & 0xFF);
if (vel > 0) inst->engine.noteOn(note, vel);
}
}
}
{
const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire);
const std::uint16_t offSeq = static_cast<std::uint16_t>(off >> 16);
if (offSeq != 0 && offSeq != previewOffConsumed_) {
// Consume UNCONDITIONALLY (mirror of the on path): a stale off left pending
// while nothing was loaded would otherwise survive until a (heal) reload lands
// and release the NEXT preview press in the same block.
previewOffConsumed_ = offSeq;
// Route the preview note-off to BOTH engines (mirror of the host note-off): a
// preview held across a reload — e.g. a curve edit committed mid-press — must
// release the old-snapshot voice now draining, not just the (fresh) live one.
// NOTE: preview shares the host-MIDI note space — noteOff releases the newest
// voice at that pitch, so a preview release can release a host-held note at
// the same pitch (inherent to routing preview through the real note path).
if (inst) inst->engine.noteOff(static_cast<int>(off & 0xFF));
if (drain) drain->engine.noteOff(static_cast<int>(off & 0xFF));
}
}
if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) {
embedPeak_.store(0.f, std::memory_order_relaxed);
return kResultOk;
}
AudioBusBuffers& out = data.outputs[0];
const int32 frames = data.numSamples;
// 64-bit host processing is not supported by the mono float core; emit silence
// rather than mis-render. REAPER runs 32-bit float by default.
if (data.symbolicSampleSize != kSample32) {
embedPeak_.store(0.f, std::memory_order_relaxed);
for (int32 ch = 0; ch < out.numChannels; ++ch) {
if (double* buf = out.channelBuffers64[ch]) {
for (int32 i = 0; i < frames; ++i) buf[i] = 0.0;
}
}
out.silenceFlags = (out.numChannels >= 64)
? ~0ULL
: ((1ULL << out.numChannels) - 1);
return kResultOk;
}
// Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked
// into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here
// we simply match the buffers the host handed us: >=2 channels -> true stereo render into
// ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the
// render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here.
float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr;
float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr;
if (ch0 && ch1) {
// Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo
// path (both channels equal), so a mono capture in stereo mode is centered, not silent.
// The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer).
for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; }
if (inst) inst->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
if (drain) drain->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
// FB1 post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so
// continuous knob drags produce no zipper noise and the true-zero bottom causes no click.
// Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the
// actual output. Branch-free inner loop; early-out when already at target. RT-safe.
{
const float gTarget = masterGain_.load(std::memory_order_relaxed);
const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step
const float gSnap = 0.5f * gStep;
const float diff = gTarget - gainCurrent_;
if (diff < -gSnap || diff > gSnap) {
// Ramp toward target: step per sample, then apply the per-sample gain.
for (int32 i = 0; i < frames; ++i) {
const float d = gTarget - gainCurrent_;
if (d > gStep) gainCurrent_ += gStep;
else if (d < -gStep) gainCurrent_ -= gStep;
else gainCurrent_ = gTarget;
ch0[i] *= gainCurrent_;
ch1[i] *= gainCurrent_;
}
} else {
gainCurrent_ = gTarget;
if (gTarget != 1.f) {
for (int32 i = 0; i < frames; ++i) { ch0[i] *= gTarget; ch1[i] *= gTarget; }
}
}
}
// Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
for (int32 ch = 2; ch < out.numChannels; ++ch) {
if (float* buf = out.channelBuffers32[ch]) {
for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i];
}
}
// Block peak (max across L/R) for the embed strip's level indicator; RT-safe.
float peak = 0.f;
for (int32 i = 0; i < frames; ++i) {
const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i];
const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i];
if (a0 > peak) peak = a0;
if (a1 > peak) peak = a1;
}
embedPeak_.store(peak, std::memory_order_relaxed);
} else if (ch0) {
// Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel;
// the replicate is defensive for a host that still hands >1 channel on a mono bus).
for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f;
if (inst) inst->engine.render(ch0, static_cast<std::size_t>(frames));
if (drain) drain->engine.render(ch0, static_cast<std::size_t>(frames));
// FB1 post-mixer master gain (mono path) — same ramp contract as the stereo branch:
// post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe.
{
const float gTarget = masterGain_.load(std::memory_order_relaxed);
const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step
const float gSnap = 0.5f * gStep;
const float diff = gTarget - gainCurrent_;
if (diff < -gSnap || diff > gSnap) {
for (int32 i = 0; i < frames; ++i) {
const float d = gTarget - gainCurrent_;
if (d > gStep) gainCurrent_ += gStep;
else if (d < -gStep) gainCurrent_ -= gStep;
else gainCurrent_ = gTarget;
ch0[i] *= gainCurrent_;
}
} else {
gainCurrent_ = gTarget;
if (gTarget != 1.f) {
for (int32 i = 0; i < frames; ++i) ch0[i] *= gTarget;
}
}
}
float peak = 0.f;
for (int32 i = 0; i < frames; ++i) {
const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i];
if (a > peak) peak = a;
}
embedPeak_.store(peak, std::memory_order_relaxed);
for (int32 ch = 1; ch < out.numChannels; ++ch) {
if (float* buf = out.channelBuffers32[ch]) {
for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i];
}
}
}
// Report silence only when nothing is loaded (lets the host optimize when idle).
// With an instrument loaded — or a drain snapshot still ringing out — we clear the
// flag so a ringing voice is not skipped.
out.silenceFlags = (inst || drain) ? 0
: ((out.numChannels >= 64)
? ~0ULL
: ((1ULL << out.numChannels) - 1));
return kResultOk;
}
IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) {
if (name && FIDStringsEqual(name, ViewType::kEditor)) {
return new ReaSamplerEditor(this);
}
return nullptr;
}
} // namespace reasampler::vst
+517
View File
@@ -0,0 +1,517 @@
// reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the
// pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio
// output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the
// engine's audio into the output bus — so a chosen bank sample plays chromatically from
// its root note in REAPER's routing/record/render path.
//
// SingleComponentEffect is the SDK's combined processor+controller base — sanctioned
// for a non-distributable, REAPER-only plugin under D5/D6. It gives us
// addAudioOutput/addEventInput, IComponent setState/getState for the instance's own
// state (the selected sample), and the IEditController seat so createView() can hand the
// host our IPlugView LICE editor.
//
// SELF-CONTAINED PLAYBACK (pS architecture correction). The instance OWNS its sample: the
// component state persists, per referenced bank sample, the project-relative WAV path +
// decode intrinsics (SampleRefs), and reloadInstrument decodes straight from that table.
// The extension's bank blob is a BROWSER SOURCE that opportunistically refreshes the refs
// when readable — NEVER a runtime requirement for playback. A project restored before the
// extension's PROJEXTSTATE parses (or with the extension absent) plays on load; the old
// reopen-heal timer + poll-to-play machinery that papered over the bank dependency is gone.
//
// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO
// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — ref resolve, WAV
// decode, keymap build, VoiceEngine construction — all happens OFF the audio thread
// (reloadInstrument, driven from the main/UI thread) and is handed to process via a
// single atomic pointer swap. See the LoadedInstrument handoff below.
#pragma once
#include <atomic>
#include <cstdint>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
#include "shell/instrument/reaper_bridge.h"
#include "core/instrument/map/sample_map.h" // PerformanceMap (the instrument's owned zoned keymap)
#include "core/instrument/map/component_state_io.h" // ComponentState codec (Q-W2v split)
#include "core/instrument/engine/sampler_core.h"
namespace reasampler::vst {
// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim
// is retired from the processor family; the engine family's symbols — Keymap, VoiceEngine,
// ChannelMode, VoiceMode, MonoTrigger, the voice-count constants — still live in flat
// `reasampler` and resolve via the enclosing namespace).
using instrument::map::ComponentState;
using instrument::map::PerformanceMap;
using instrument::map::SampleRefs;
using instrument::map::kPreviewVelocityDefault;
class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface)
// One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice
// engine that plays it. The engine holds references into the keymap, so the two MUST live
// and die together at a STABLE address — hence this is heap-allocated and neither copyable
// nor movable. The audio thread only ever reads it through an atomic pointer; it is built
// and destroyed off the audio thread.
//
// installedAt: the reloadGeneration_ value at which this instrument was atomically
// installed into live_. Set on the reload path before the exchange. process() publishes
// this field (not a fresh re-read of reloadGeneration_) so the published generation is
// exactly the generation of the instrument actually in hand for the block.
struct LoadedInstrument {
Keymap keymap;
VoiceEngine engine;
std::uint64_t installedAt = 0; // reload generation at which this was installed
// The takeover declick (GA fix, rev 2) is opted IN here — the PRODUCT default: any
// restart of a sounding voice (mono Retrigger takeover/fallback, cross-sample legato
// restart, POLY at-cap steal — the preview note included, now that it is a real pool
// voice) smooths the cut via the difference-seeded ramp instead of clicking. The pure
// core defaults it off (regression baseline) — same layering as kDefaultPitchEngine.
LoadedInstrument(Keymap km, std::size_t maxVoices,
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
std::int64_t preserveWindowFrames = 0,
VoiceMode voiceMode = VoiceMode::Poly,
MonoTrigger monoTrigger = MonoTrigger::Retrigger)
: keymap(std::move(km)),
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames,
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
installedAt(gen) {}
// True when nothing in this snapshot is sounding. process() publishes this for the
// drain slot so the off-thread retirer can park an idle drain in the graveyard early
// (FA1-review Major #2). Bounded scan (<= maxVoices).
bool fullyIdle() const { return engine.activeVoiceCount() == 0; }
LoadedInstrument(const LoadedInstrument&) = delete;
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
};
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
public:
ReaSamplerProcessor() = default;
// Out-of-line so the owned ReaSamplerEmbed (held by unique_ptr, forward-declared here)
// is a complete type at the destruction point (defined in the .cpp).
~ReaSamplerProcessor() override;
// The factory create function (registered in vst_entry.cpp).
static Steinberg::FUnknown* createInstance(void* /*context*/);
//--- from IComponent / IPluginBase -------------------------------------
// Connects the REAPER bridge (context is REAPER's IHostApplication) and declares
// the instrument bus topology.
Steinberg::tresult PLUGIN_API initialize(Steinberg::FUnknown* context) override;
Steinberg::tresult PLUGIN_API terminate() override;
Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override;
// Instance state = the selected bank sample id (D-B: a performance choice the
// instrument owns; NEVER written back to the bank). Component-state, so a saved
// REAPER project restores which sample each instance plays.
Steinberg::tresult PLUGIN_API setState(Steinberg::IBStream* state) override;
Steinberg::tresult PLUGIN_API getState(Steinberg::IBStream* state) override;
//--- from IAudioProcessor ----------------------------------------------
Steinberg::tresult PLUGIN_API setupProcessing(
Steinberg::Vst::ProcessSetup& setup) override;
// Marshals MIDI -> VoiceEngine -> audio output. Real-time safe (no alloc/IO/lock).
Steinberg::tresult PLUGIN_API process(
Steinberg::Vst::ProcessData& data) override;
// Output-bus negotiation. The instrument has ONE canonical output arrangement: a FIXED
// stereo bus (GA fix — the channel mode is a decode policy, never a bus fact; mono mode
// renders dual-mono through it). We accept the host's proposal only when it is a single
// stereo output; otherwise we reject (kResultFalse) but keep our stereo arrangement, so
// getBusArrangement / getBusInfo always report 2 channels and the host routes accordingly.
Steinberg::tresult PLUGIN_API setBusArrangements(
Steinberg::Vst::SpeakerArrangement* inputs, Steinberg::int32 numIns,
Steinberg::Vst::SpeakerArrangement* outputs, Steinberg::int32 numOuts) override;
//--- from IEditController -----------------------------------------------
// Hands the host our LICE IPlugView editor.
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
// Override queryInterface to additionally expose REAPER's IReaperUIEmbedInterface (S6):
// REAPER queries the IEditController for it to drive the inline TCP/MCP embed surface.
// All other iids delegate to SingleComponentEffect's implementation unchanged.
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
void** obj) override;
// The embedded-strip activity level (0..1), read by the S6 embed shell on the UI thread.
// Backed by embedPeak_, the per-block mono peak the audio thread stores relaxed — a
// lock-free advisory readout, never touched with a lock the audio thread could contend.
double embedActivityLevel() const {
return static_cast<double>(embedPeak_.load(std::memory_order_relaxed));
}
// Called by the editor (main/UI thread) when the user picks a sample, and internally
// on load. SELF-CONTAINED (pS): resolves the selection/zones against the instance-OWNED
// SampleRefs table, decodes each WAV OFF the audio thread, and publishes the built
// instrument to process() via an atomic swap — NO bank read is required for playback.
// When the live bank blob IS readable it is first folded into the refs table
// (refreshRefsFromBank), which is both the browser's copy-the-ref-in mechanism and the
// S9 live-recapture sync. A missing/unreadable WAV is the defined no-play (silence, no
// retry). Returns the resolved selection id ("" if nothing was loaded) for the editor.
std::string reloadInstrument();
// The result of a bank-sync poll (S9/S8): what pollBankSync did this tick, so the editor
// can react (repaint / re-snapshot its own view) only when something actually changed.
struct BankSyncResult {
// The bank generation changed (or a pre-v10 legacy lift landed an instrument) ->
// reloadInstrument ran and the editor should re-snapshot its bank view.
bool reloaded = false;
bool applied = false; // a new assignment request was applied -> selection changed
};
// Poll the S9 bank-generation counter and the S8 assignment request over the bridge, OFF
// THE AUDIO THREAD (the editor's UI timer drives this — NEVER process()). This is an
// EDITOR/BROWSER sync path — playback never depends on it (pS). Semantics:
// * S9: if the bank generation differs from what we last saw, call reloadInstrument() so
// a recapture/ingest refreshes playback hands-free (atomic swap, glitch-free).
// * S8: if a NEW (generation > last consumed) assignment request names a resolvable
// sample AND this instance is the target (isFocusedTarget), apply it as the selection
// and reload; an unresolvable request is DROPPED silently (marker advanced, no change);
// a non-target instance neither applies nor advances its marker.
// * LEGACY LIFT: a pre-v10 blob restored with intent but no refs retries the (cheap)
// bank read until the blob is parseable, then reloads ONCE to copy the refs in.
// TERMINATING: once the blob parses and NO referenced id resolves, the ids are
// provably stale — the lift concludes permanently (legacyLiftShouldRun) instead of
// churning a full bank read + reload every tick forever.
// The consumed marker advances in component state (marked dirty via the host handler) so a
// re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input
// (the editor passes true only for the instance whose editor is open — see the handoff).
// Idempotent on an idle tick (generation unchanged + no new request -> no work).
BankSyncResult pollBankSync(bool isFocusedTarget);
// The bridge, for the editor's live-state readout + sample list. Owned here; the
// editor borrows it (outlives the editor).
ReaperBridge& bridge() { return bridge_; }
// The live host sample rate latched from setupProcessing (the SAME rate reloadInstrument
// resolves seconds->frames against). The editor's S-VIEW-3 envelope overlay reads it to place
// its wall-clock seconds on the same time base the voice engine plays them over. 0.0 before
// setupProcessing runs (the editor guards). Read on the UI thread; a plain load — sampleRate_
// is set once by setupProcessing before any audio and does not change under the editor.
double sampleRate() const { return sampleRate_; }
// The current single-capture selection id (main/UI thread reads for the editor). Guarded
// by selectionMutex_ — never touched on the audio thread. Since S10 this is the ONE picked
// capture the default face plays chromatically when the performance map is empty; an EMPTY
// id resolves to SILENCE (no first-sample fallback). A non-empty zoned map supersedes it.
std::string selectedSampleId();
void setSelectedSampleId(const std::string& id);
// The performance map (Tier 1: the zoned keymap the instrument owns; D-B). Read/written
// by the editor on the UI thread; snapshotted under performanceMutex_. NEVER read on the
// audio thread — reloadInstrument bakes it into the LoadedInstrument's Keymap off-thread.
PerformanceMap performanceMap();
void setPerformanceMap(const PerformanceMap& map);
// The per-instance channel mode (S7, D-E: mono | stereo). Read/written on the UI thread
// (the editor toggle) and read off-thread by getState/reloadInstrument; guarded by
// channelModeMutex_. NEVER read on the audio thread — process() renders against the host's
// negotiated output channel count, and reloadInstrument bakes the mode into the decode.
// GA fix: the mode is a DECODE policy only (downmix vs L/R split). The output bus is a
// FIXED stereo bus — mono mode renders dual-mono through it (centered) — so a mode change
// never renegotiates host I/O (the mono<->stereo bus flip's live pin remap was the
// hard-right-pan defect).
ChannelMode channelMode();
// Sets the mode from the EDITOR TOGGLE (a deliberate user choice): latches the mode
// EXPLICIT (the GA auto-default stops fighting it), and on a CHANGE reloads the instrument
// so the next block decodes the new channel count. UI thread only.
void setChannelMode(ChannelMode mode);
// The per-instance preview-trigger velocity (S-VIEW-4, MIDI 1..127). Read/written on the
// UI thread (the Sample-view velocity knob) and by getState/setState (host load-save thread);
// guarded by previewMutex_. Persisted in component state (v6). NOT read on the audio thread.
std::uint8_t previewVelocity();
void setPreviewVelocity(std::uint8_t velocity);
// --- Phase S voice-system parameters (per-instance, persisted in component state v7) ---
// Read/written on the UI thread (the editor's voice deck) and by getState/setState; guarded
// by voiceParamsMutex_. NOT read on the audio thread — each setter rebuilds the VoiceEngine
// OFF-thread via rebuildVoiceEngine (a LIGHT rebuild around the already-decoded keymap; no
// bridge read, no WAV re-decode) published through the same tail-preserving drain-slot swap,
// so changing polyphony / mode / the retrigger toggle never cuts a ringing tail.
int voiceCount();
void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode();
void setVoiceMode(VoiceMode mode);
MonoTrigger monoTrigger();
void setMonoTrigger(MonoTrigger trigger);
// --- FB1 post-mixer master gain (per-instance, persisted in component state v8) ---------
// LINEAR gain in [0, masterGainMaxLinear()] (0.0 = -inf/true silence, 1.0 = unity, cap =
// +24 dB; the pure master_gain module owns the dB knob taper). Held in an atomic so the
// audio thread applies it with ONE relaxed load per block as a post-sum multiply over the
// rendered output (engine + drain + preview) — no lock, no rebuild, no per-voice cost.
// Written by the editor's Gain knob (UI thread) and setState; read by getState + process().
double masterGainLinear() const {
return static_cast<double>(masterGain_.load(std::memory_order_relaxed));
}
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
// Fire a one-shot PREVIEW note-on / note-off through the live instrument's MAIN
// VoiceEngine — the SAME noteOn/noteOff calls host MIDI takes, so a preview is a REAL
// voice: it counts against the voice count, can steal / be stolen, and respects
// Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's
// isolation — preview must obey voicing). The editor posts the loaded capture's /
// selected zone's ROOT note (plays at unity); previewNoteOn plays it at the current
// previewVelocity() (the velocity curve applies); previewNoteOff releases it (Gate) —
// Trigger zones ignore note-off and play through. OFF the audio thread (the editor's
// preview-trigger button, UI thread); the request is handed to process() via a
// lock-free single-slot mailbox drained at block start — no allocation, no lock on the
// audio thread. A momentary button (down = on, up = off) reads as a natural key press.
// This is PLAYBACK ONLY: it never captures, never inserts a timeline item.
void previewNoteOn(int note);
void previewNoteOff(int note);
// The instance-owned sample refs (pS self-contained playback): a snapshot copy for the
// editor (waveform/loop-intrinsic fallback when the bank blob is not readable). UI
// thread; guarded by refsMutex_.
SampleRefs sampleRefs();
private:
// Phase S drain retirement (FA1-review Major #2): if process() has published that the
// CURRENT drain instrument is fully idle (every engine voice silent),
// move it out of the drain slot into the graveyard and prune — so an edited-away snapshot
// stops costing resident memory as soon as its tails die, instead of squatting in the slot
// until the NEXT reload. Off the audio thread only (takes reloadMutex_); driven from
// pollBankSync's UI-timer tick (the same cadence that drives reloads — an idle drain with
// no editor open simply waits for the next reload/deactivate, exactly the pre-fix bound).
// Safe against a racing process(): idleness is monotone (the drain receives no note-ons)
// and the published value names the drain's OWN installedAt, so a stale publication about
// an OLDER drain can never retire a newer one; the graveyard prune's monotone-generation
// proof (see below) covers the free.
void retireIdleDrain();
// Phase S voice-param LIGHT rebuild (voice-review Major #3): rebuild the engine
// around a COPY of the LIVE instrument's already-decoded Keymap — no bridge read, no
// filesystem, no WAV re-decode — and publish through the same tail-preserving drain-slot
// swap as a full reload. A polyphony/mode/trigger change touches no audio data, so the
// full reloadInstrument (which re-decodes every zone WAV from disk on the UI thread) was
// pure waste — a visible UI stall on a many-zone instrument. Copying the keymap is safe:
// it is immutable after construction and, under reloadMutex_, the live instrument can
// neither be swapped nor freed while we read it. When nothing is loaded this is a no-op —
// the new params bake into the next real reload. Off the audio thread only.
void rebuildVoiceEngine();
// The pre-v10 LEGACY LIFT gate (#A): true when a lift attempt this tick could make
// progress. Latches legacyLiftConcluded_ on a Stale proof (see the member below); the
// pure decision itself is sample_map's legacyLiftDecision. Off the audio thread only
// (bridge read + bank parse).
bool legacyLiftShouldRun();
// Publish `built` (null = install silence) into live_: prune the graveyard by the last
// process()-published generation, swap `built` into live_, displace the previous live into
// the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES
// reloadMutex_ held — factored out so reloadInstrument and rebuildVoiceEngine share the ONE
// safety-critical swap dance (see the handoff proof below).
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
// pS-usage: publish this instance's held captures to its per-instance ext-state key
// ("rsusage_<instanceGuid>") so the extension's prune counts them as referenced — a
// capture a live instance holds can never be pruned. Called at the end of every
// reloadInstrument (the ONE choke point every play-set change funnels through:
// selection change, zone edits, assignment consume, bank refresh, setState load), so
// publishing is EAGER and needs no timer — a closed-editor instance's record is
// already in ext-state from its last change/load. OFF THE AUDIO THREAD only (bridge
// calls). Mints instanceGuid_ on first need; RE-mints when planUsagePublish detects
// this state was cloned onto another track (FX copy / track duplication). Idempotent
// on an unchanged play-set (skipWrite). `refs`/`ids` are reloadInstrument's own
// snapshot — the refs table and the id set the instance currently plays.
void publishUsage(const SampleRefs& refs, const std::vector<std::string>& ids);
ReaperBridge bridge_;
// --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) --
// process() atomically loads `live_` AND `draining_` at block start and marshals/renders
// against them — two atomic acquires, no lock, no free on the audio thread.
//
// reloadInstrument() (off-thread, serialized by reloadMutex_) builds a new
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
// NOT freed and NOT silenced: it moves into `draining_`, where process() keeps
// rendering its already-sounding voices (and routes note-offs to it) so a reload —
// a curve/param edit, a bank-generation refresh, an applied assignment — never cuts a
// ringing note (FA1, bug 3b). New note-ons go ONLY to the live instrument, so the next
// trigger plays the new state. The instrument evicted FROM the drain slot (two reloads
// old) is parked in `graveyard_` for reclaim — a rapid second reload hard-cuts only the
// oldest edit's tails (bounded compromise, documented).
//
// Bounded reclaim: process() publishes the MINIMUM installedAt over the (non-null)
// pointers it holds this block via processGeneration_ — a single atomic store, RT-safe.
// The reload path frees graveyard entries whose installedAt < seen (the last published
// value).
//
// Safety argument: both slots are monotone in installedAt over time (live_ receives
// successively newer builds; draining_ receives successively newer displaced lives), so
// the published minimum is monotone across blocks, and any future process() load yields
// installedAt >= seen. An entry only reaches the graveyard by leaving BOTH slots
// (single-writer under reloadMutex_), so a graveyard entry with installedAt < seen can
// never again be loaded and is not currently held — freeing it is safe. process()
// publishes BEFORE rendering, so the pointers it renders with are covered by the value
// the pruner reads (a stale lower read is merely conservative).
//
// The graveyard's upper bound is the number of reloads since process last ran
// (typically 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*> draining_{nullptr}; // displaced instrument still rendering its tails
std::atomic<std::uint64_t> reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process)
std::atomic<std::uint64_t> processGeneration_{0}; // min installedAt held by process (written on audio thread, read off-thread)
// Phase S: the installedAt of the drain instrument process() last observed FULLY IDLE
// (every engine voice silent; 0 = none / the current drain still sounds). Written relaxed on the audio thread each
// block; read by retireIdleDrain() off-thread. Naming the generation (not a bool) closes
// the swap race: a publication about an old drain can never retire its successor.
std::atomic<std::uint64_t> drainIdleGeneration_{0};
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
// The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence).
// Off-thread only; a small mutex guards the string against a getState/editor race. NOT
// read on the audio thread.
std::mutex selectionMutex_;
std::string selectedSampleId_;
// The performance map (Tier 1: the instrument's owned zoned keymap). Off-thread only;
// guarded against a getState/editor race. NOT read on the audio thread — reloadInstrument
// bakes it into the LoadedInstrument's Keymap under the reload lock.
std::mutex performanceMutex_;
PerformanceMap performanceMap_;
// The instance-OWNED sample refs (pS self-contained playback): the path + intrinsics
// per referenced bank sample that setState restores, reloadInstrument resolves/decodes
// from, and getState persists (v10). Refreshed opportunistically from the bank blob
// when it is readable; NEVER a bank dependency for playback. Off-thread only (UI +
// load/save + reload); guarded against a getState/reload race. NOT read on the audio
// thread.
std::mutex refsMutex_;
SampleRefs sampleRefs_;
// pS-usage publish identity + lifetime nonce (see publishUsage). instanceGuid_ is
// the persisted per-instance identity (ComponentState v11; empty until first
// publish); usageNonce_ is THIS incarnation's per-LIFETIME owner nonce, carried
// INSIDE the published wire (UsageRecord.ownerNonce) — planUsagePublish's exact
// ownership discriminator between "my own write" (clean replace) and "a foreign
// writer" (union / re-mint). NEVER persisted: a persisted nonce would clone with
// the state on FX copy, and two same-track copies converging on byte-identical
// wires is exactly the ambiguity the nonce exists to break (a wire-equality
// discriminator let sibling A clean-replace over sibling B's still-held paths —
// the delete direction). Minted lazily on first publish; cleared on setState (a
// restored blob is a new lifetime). Guarded by usageMutex_ (publish runs under
// reloadMutex_ but getState/setState do not).
std::mutex usageMutex_;
std::string instanceGuid_;
std::string usageNonce_;
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadInstrument);
// guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read
// on the audio thread — process renders against the host's negotiated output channel count.
// channelModeExplicit_ (GA, persisted v9): false = the mode is an un-touched default that
// reloadInstrument may auto-default from the loaded capture's channel count; true = the user
// deliberately toggled the mode (setChannelMode latches it) and it is never fought.
std::mutex channelModeMutex_;
ChannelMode channelMode_ = ChannelMode::Mono;
bool channelModeExplicit_ = false;
// The last assignment-request generation this instance CONSUMED (S8 reader). Persisted in
// component state (v5) so a re-open does not re-apply a request the user already got and
// then changed away from. Written by pollBankSync (UI/timer thread) and getState; read by
// pollBankSync + getState; seeded by setState. Guarded against a getState/poll race. NEVER
// read on the audio thread. Default 0 -> a genuinely new first assign (gen >= 1) applies.
std::mutex assignMarkerMutex_;
std::int64_t lastConsumedAssignGeneration_ = 0;
// The bank generation this instance last SAW (S9 reader). UI/timer-thread only (pollBankSync
// is the sole reader/writer) — no mutex needed, and it is NOT persisted. Initialized to a
// -1 SENTINEL (no real generation can be negative — parseBankGeneration yields >= 0) so the
// FIRST poll after an editor open BASELINES the seen value without a redundant reload
// (setState already loaded the instrument from the OWNED refs); a subsequent generation
// CHANGE then drives the reload. Since pS there is NO reopen-heal here: playback never
// depends on this poll — a v10 blob plays from its own refs at setState time. Besides a
// generation change, pollBankSync reloads only for an APPLIED S8 assignment and for the
// pre-v10 LEGACY LIFT. NOT read on the audio thread.
std::int64_t lastSeenBankGeneration_ = -1;
// The pre-v10 LEGACY LIFT's terminating latch (#A): set once legacyLiftShouldRun proves
// the referenced ids STALE against a readable bank blob (LegacyLiftDecision::Stale) —
// there is nothing to lift, so the lift stops re-firing (the steady state is one relaxed
// load per tick, no bank read). Reset by setState (a new blob = new facts). NOT consulted
// by the genChanged/applied reload paths, so a later bank change that re-introduces an id
// (e.g. an extension-side undo) still refreshes the refs — the latch only gates the lift.
// Atomic: written on the UI-timer thread (pollBankSync) and the host load thread (setState).
std::atomic<bool> legacyLiftConcluded_{false};
// S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the
// user's chosen strike velocity survives a project save/reload. Since Wave 2 the Sample-view
// velocity knob writes it on the UI thread, so it is guarded by previewMutex_; setState and
// getState (load/save thread) share the same guard. Default kPreviewVelocityDefault (64). NOT
// read on the audio thread.
std::mutex previewMutex_;
std::uint8_t previewVelocity_ = kPreviewVelocityDefault;
// Phase S voice-system parameters (per-instance, persisted in component state v7). Off-thread
// only (UI voice deck + getState/setState + reloadInstrument); guarded against a getState/editor
// race. Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior. NOT read on the audio
// thread — reloadInstrument bakes them into the LoadedInstrument's engine off-thread.
std::mutex voiceParamsMutex_;
int voiceCount_ = kDefaultVoiceCount;
VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
// FB1 post-mixer master gain (LINEAR; persisted in component state v8). A lock-free
// atomic — the target the UI thread writes; the audio thread ramps gainCurrent_ toward
// it per-sample each block (linear interpolation, ~20 ms wall-clock at every host rate)
// so sudden knob moves produce no zipper noise and the true-zero bottom causes no click.
std::atomic<float> masterGain_{1.0f};
// The audio-thread running gain value: tracks masterGain_ across blocks, stepping at
// most gainRampStep_ per sample toward the target. Starts at unity (pre-FB1 default).
// Written and read exclusively on the audio thread — no atomics needed.
float gainCurrent_ = 1.0f;
// T3-01: the per-sample ramp step, derived from kGainRampSeconds (20 ms wall-clock)
// against the live host rate in setupProcessing — never a baked-in rate. The default is
// the 48 kHz value so behavior before the first setupProcessing is unchanged. Written in
// setupProcessing (host-serialized against process), read on the audio thread.
float gainRampStep_ = 1.0f / 960.0f;
// --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) ---------
// The editor's preview-trigger button posts a note-on/off request from the UI thread; process()
// drains it at block start and drives the live instrument's MAIN VoiceEngine — the same
// noteOn/noteOff host MIDI takes, so the preview obeys voicing. ONE slot per direction, each a packed
// request whose high bits are a monotonically-incrementing sequence so process() detects a NEW
// request by comparing against the last sequence it consumed (never re-firing a stale one). The
// low 8 bits carry the note (on) / note (off); the on request also carries the velocity in the
// next 8 bits, latched at post time so the audio thread reads no shared velocity field. A single
// relaxed atomic load per block on the audio thread — RT-safe (no alloc, no lock).
// packed = (seq << 16) | (velocity << 8) | note [note-on]
// packed = (seq << 16) | note [note-off]
std::atomic<std::uint32_t> previewOnRequest_{0}; // 0 = no request posted yet
std::atomic<std::uint32_t> previewOffRequest_{0};
std::uint16_t previewOnSeq_ = 0; // UI-thread post counter (never 0 after first post)
std::uint16_t previewOffSeq_ = 0;
std::uint16_t previewOnConsumed_ = 0; // audio-thread: last on-seq fired
std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired
// Latched from setupProcessing so setActive/reload can size against it. Read
// off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate
// before any audio, and reloadInstrument guards on it before use.
double sampleRate_ = 0.0;
Steinberg::int32 maxBlockSize_ = 4096;
// --- S6 embedded TCP/MCP UI ---------------------------------------------
// The embed shell (IReaperUIEmbedInterface), created lazily on the first queryInterface
// and owned here for the processor's lifetime. REAPER borrows AddRef'd references from
// queryInterface; the shell's refcount is a no-op because THIS unique_ptr governs its
// destruction (the processor always outlives the borrowed references).
std::unique_ptr<ReaSamplerEmbed> embed_;
// The per-block mono peak (0..1+) the audio thread stores relaxed; the embed strip's
// level indicator reads it via embedActivityLevel(). Advisory only — a plain atomic,
// no ordering coupling, never guarded by a lock the audio thread touches.
std::atomic<float> embedPeak_{0.f};
};
} // namespace reasampler::vst
+1 -1
View File
@@ -24,7 +24,7 @@
#include "core/version/app_version.h" // vstPluginName / appVersion — the channel-derived identity
#include "ext_keys.h" // kProjExtNamespace — the pairing-surface assertion target
#include "reasampler_processor.h"
#include "shell/instrument/reasampler_processor.h"
#include "shell/instrument/reasampler_vst.h" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*)
// CHANNEL PAIRING INVARIANT (S18). The instrument's PLUGIN identity forks by the ONE channel