// 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 #include #include #include #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{}; banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; 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 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 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(layout.channelCount); } return channelPcm_; } const std::vector& 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 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 bytes = readFileBytes(abs); // empty on any failure const WavLayout layout = parseWavLayout(bytes); if (layout.valid) { std::vector 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& 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::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