Files
reasampler/src/shell/instrument/editor_session.cpp
T
daniel aedcc6976c fix: pin the waveform arbitration in a testable predicate, close round-4 review minors
Extracts resolveWaveformClaim (core/instrument/ui/spline_edit) so the shell's node/tab/marker click resolution is unit-tested directly, not just its input geometry; folds the staged-envelope node into it; fixes comment accuracy, a cost regression, and test fidelity issues.
2026-08-01 00:17:06 -04:00

356 lines
16 KiB
C++

// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the
// commit-and-reload seam, selection loading, the loaded capture's marker resolution, and
// the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits off the audio
// thread via the processor's reloadInstrument.
#include "shell/instrument/reasampler_editor.h"
#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 path resolution)
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames
#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (the pure key)
#include "core/util/file_bytes.h" // shared whole-file loader
#include "ext_keys.h"
#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span)
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (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::engine::loop::LoopBounds;
using instrument::engine::loop::defaultLoopBounds;
using instrument::ui::nameMatchesQuery;
using ui::ThumbnailKey;
using ui::thumbnailKeyString;
using util::readFileBytes;
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) {
// The default IS the enforced floor (checkSizeConstraint) — the face opens at the size its
// band stack is laid out for and can only be grown from there.
ViewRect r(0, 0, instrument::ui::kEditorMinWidth, instrument::ui::kEditorMinHeight);
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 waveform + snap source)
channelPcmId_.clear();
channelPcm_ = ChannelPcm{};
if (!processor_) {
samples_.clear();
banks_.clear();
visible_.clear();
selectedId_.clear();
params_ = InstrumentParams{};
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();
params_ = processor_->instrumentParams();
channelMode_ = processor_->channelMode();
voiceCount_ = processor_->voiceCount();
voiceMode_ = processor_->voiceMode();
monoTrigger_ = processor_->monoTrigger();
// A refresh that emptied the selection closes the curve popup — an open-but-invisible
// modal would otherwise swallow clicks on the empty state.
if (selectedId_.empty()) closeCurvePopup();
// 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() {
// Bank filter first, then type-to-filter search narrows by name substring.
visible_.clear();
for (const SampleChoice& s : samples_) {
const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_;
if (!inBank) continue;
const std::string& name = s.displayName.empty() ? s.id : s.displayName;
if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s);
}
// scrollOffset_ is clamped at paint/wheel time (where layout is known); this runs on
// the sync-timer refresh too, so it must not reset the user's scroll here.
}
#ifdef _WIN32
// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path).
void ReaSamplerEditor::onSyncTimer() {
// UI thread (WM_TIMER). Never while a drag is in flight: a reload mid-drag would
// rebuild the instrument and repaint under the cursor, yanking the edit — the next
// tick picks up the change after release.
if (!processor_) return;
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
// An open editor is the focused assignment target (thundering-herd policy); instances
// with no editor open never poll (the timer is bound to the child window).
const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true);
// Re-snapshot only when something changed.
if (r.reloaded || r.applied) {
refreshFromBank();
invalidate();
}
// 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. Publishes the edited selection + parameter set, then rebuilds off the
// audio thread. The reload also copies the loaded capture's file ref + intrinsics into
// the instance-owned refs table — a browser load is the moment the instance becomes
// self-contained for that sample.
if (!processor_) return;
processor_->setSelectedSampleId(selectedId_);
processor_->setInstrumentParams(params_);
processor_->reloadInstrument();
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
// toggle draws what the engine actually decoded with.
channelMode_ = processor_->channelMode();
#ifdef _WIN32
invalidate();
#endif
}
void ReaSamplerEditor::commitLive() {
// UI thread only. See the declaration for why this still writes the parameter set.
if (!processor_) return;
processor_->setInstrumentParams(params_);
processor_->publishLiveParams();
}
bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const {
// The decision itself is the pure liveCommitFor's; this is only the shell's drag-kind
// vocabulary mapped onto it, so the routing is pinned by deck_groups' tests rather than
// by inspection of this file.
using instrument::ui::LiveDragKind;
const LiveDragKind k = kind == DragKind::kDeckKnob ? LiveDragKind::kDeckKnob
: kind == DragKind::kEnvNode ? LiveDragKind::kEnvNode
: LiveDragKind::kOther;
return instrument::ui::liveCommitFor(k, paramId);
}
void ReaSamplerEditor::closeCurvePopup() {
curvePopup_ = CurveTarget::kNone;
if (drag_ == DragKind::kCurveNode) {
drag_ = DragKind::kNone;
curvePointIndex_ = -1;
}
}
void ReaSamplerEditor::loadSelection(const std::string& id) {
// A load REPLACES the loaded sound. The shaping parameters (play mode, envelopes, pitch
// engine, key-track, velocity curve) are NOT reset — the one set governs whatever is
// loaded, so a load swaps the sound and keeps the settings. The three CAPTURE-ANCHORED
// overrides are: a root, a loop span and a start frame all name positions in the
// OUTGOING capture and mean nothing in the new one, so they clear and the new capture
// plays from its own bank intrinsics. The loop crossfade goes with the span it belongs
// to — it is a length in the outgoing capture's frames.
selectedId_ = id;
params_.rootOverride.reset();
params_.loopOverride.reset();
params_.loopCrossfadeFrames = 0;
params_.startPoint.reset();
commitAndReload();
}
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
SetupMarkers m;
// Seed from the bank's intrinsic loop (fact about the file), then let the parameter set's
// override win (the instrument's performance choice). Read the loop intrinsic from the
// live bank blob (the same path selectSample uses); when that is not readable (extension
// absent / not yet parsed) the instance-owned ref carries the same intrinsics. Skipped
// entirely once an override is already set — it would just be overwritten below, and the
// bridge read + JSON parse it costs is real (mouseDownWaveform's arbitration calls this on
// every waveform click, not just marker grabs, to know whether a tab or marker candidate
// hits at all).
if (processor_ && !params_.loopOverride) {
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 parameter set's override (loop + start) supersedes the intrinsic.
if (params_.loopOverride) {
m.hasLoop = params_.loopOverride->hasLoop;
m.loopStart = params_.loopOverride->start;
m.loopEnd = params_.loopOverride->end;
}
if (params_.startPoint) m.start = *params_.startPoint;
m.crossfade = params_.loopCrossfadeFrames;
// A collapsed or inverted span is the OFF state (the engine refuses it either way), so
// park the handles on the shared default rather than leaving them stacked on each other
// where neither could be grabbed apart again. The markers are still drawn at 'no loop'
// weight — drag one to CREATE a loop.
if (!m.hasLoop || m.loopEnd <= m.loopStart) {
m.hasLoop = false;
const LoopBounds d = defaultLoopBounds(frames);
m.loopStart = d.start;
m.loopEnd = d.end;
}
return m;
}
void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) {
// Write the edited markers into the parameter set as the loop/start override. The bank
// intrinsic is never written (read-only bank consumer).
SampleLoop loop;
// Collapsing the span onto itself is the OFF gesture — record it as such so the next
// pickedMarkers re-offers the default handles instead of two coincident ones.
loop.hasLoop = m.hasLoop && m.loopEnd > m.loopStart;
loop.start = m.loopStart;
loop.end = m.loopEnd;
params_.loopOverride = loop;
// OFF parks the crossfade at 0 too — loadSelection's own clear (a fresh capture has no
// loop to fade) is the same rule; leaving a stale length here would silently re-apply it
// (clamped) the next time a loop is dragged back in.
params_.loopCrossfadeFrames = loop.hasLoop ? m.crossfade : 0;
params_.startPoint = m.start;
}
int ReaSamplerEditor::effectiveRoot() const {
if (params_.rootOverride) return *params_.rootOverride;
for (const SampleChoice& s : samples_) {
if (s.id == selectedId_ && s.rootNote) return *s.rootNote;
}
return 60;
}
std::string ReaSamplerEditor::samplePathFor(const std::string& sampleId) const {
// SampleChoice is the browser's metadata projection and does not carry the WAV path, so
// resolve it from the live bank blob (selectSample).
if (!processor_) return {};
auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
if (banksJson) {
// An empty relativePath (found the entry, but it carries no path) falls through to
// the refs fallback below rather than short-circuiting on it.
if (auto sel = selectSample(*banksJson, sampleId); sel && !sel->relativePath.empty()) {
return sel->relativePath;
}
}
// 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)) return r->relativePath;
return {};
}
const ReaSamplerEditor::ChannelPcm& ReaSamplerEditor::channelPcmFor(
const std::string& sampleId) {
if (channelPcmId_ == sampleId) return channelPcm_;
// A failed decode is still cached (channelCount stays 0) so a broken/missing file is not
// re-read on every paint.
channelPcmId_ = sampleId;
channelPcm_ = ChannelPcm{};
const std::string relativePath = samplePathFor(sampleId);
if (relativePath.empty()) return channelPcm_;
const std::string projectDir = processor_->bridge().activeProjectDir();
const std::vector<std::uint8_t> bytes =
readFileBytes(resolveBankFile(projectDir, relativePath)); // empty on any failure
const WavLayout layout = parseWavLayout(bytes);
if (layout.valid) {
channelPcm_.interleaved = extractFloatFrames(bytes, layout, 0, layout.frameCount());
channelPcm_.channelCount = static_cast<int>(layout.channelCount);
}
return channelPcm_;
}
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
auto it = pcmCache_.find(sampleId);
if (it != pcmCache_.end()) return it->second;
// 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::vector<AudioSample> mono;
const std::string relativePath = samplePathFor(sampleId);
if (!relativePath.empty()) {
const std::string projectDir = processor_->bridge().activeProjectDir();
const std::string abs = resolveBankFile(projectDir, relativePath);
const std::vector<std::uint8_t> bytes = readFileBytes(abs); // empty on any failure
const WavLayout layout = parseWavLayout(bytes);
if (layout.valid) {
std::vector<AudioSample> interleaved =
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) {
// Key through the pure ThumbnailKey (bank_grid, length-prefixed id — collision-proof) so
// both thumbnail pipelines share one tested key grammar. The editor invalidates by
// wholesale clear() on refresh/resize, so the bank generation carries no information here.
const std::string key =
thumbnailKeyString(ThumbnailKey{sampleId, binCount, /*generation=*/0});
auto it = thumbCache_.find(key);
if (it != thumbCache_.end()) return it->second;
// Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by
// every thumbnail width AND the 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