// 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 #include #include #include #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{}; banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; selectedId_ = processor_->selectedSampleId(); const auto prevZoneCount = static_cast(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(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(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 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(map_.zones.size()); ++i) { PerformanceZone& z = map_.zones[static_cast(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(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(map_.zones.size()); ++i) { if (map_.zones[static_cast(i)].sampleId == selectedId_) return i; } PerformanceZone z; z.sampleId = selectedId_; z.lowNote = 0; z.highNote = 127; map_.zones.push_back(z); return static_cast(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& 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 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 bytes = readFileBytes(abs); 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) { // 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& 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