Files
reasampler/src/shell/instrument/editor_session.cpp
T

372 lines
17 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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