Files
reasampler/src/vst/reasampler_editor.cpp
T

1135 lines
53 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.
// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the
// ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is
// guarded so a non-Windows build (not a target) degrades to the CPluginView defaults.
#include "reasampler_editor.h"
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <string>
#include <vector>
#include "capture_browser.h"
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
#include "editor_geometry.h" // Rect, contains
#include "ext_keys.h"
#include "keyboard_strip.h"
#include "peaks.h" // computeEnvelope
#include "reaper_bridge.h"
#include "reasampler_processor.h"
#include "app_version.h" // vstPluginName (channel-derived editor title band, S18)
#include "sample_map.h"
#include "wav_trim.h" // parseWavLayout, extractFloatFrames
#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11)
#ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
#include "wdltypes.h"
#include "lice/lice.h"
#endif
using namespace Steinberg;
namespace reasampler::vst {
namespace {
#ifdef _WIN32
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: fast enough
// to feel hands-free, slow enough to be free. The id is a per-window SetTimer id (any nonzero).
constexpr UINT_PTR kSyncTimerId = 1;
constexpr UINT kSyncTimerIntervalMs = 500;
// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry
// is in capture_browser / keyboard_strip). The title band names the plugin + a live
// readout; the toggle band carries the Browser/Zones switch; the setup band (single-
// capture face) hosts the keyboard strip + level readout under the browser.
constexpr int kTitleHeight = 24;
constexpr int kToggleHeight = 22;
constexpr int kSetupHeight = 176; // the single-capture setup surface (labels + waveform + strip)
constexpr int kStripBandHeight = 40;
constexpr int kWaveformHeight = 72; // the S11 waveform band inside the setup surface
// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads as
// the same tool. (Phase L's L1 kit replaces these flat fills later; not gated on it.)
const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255);
const LICE_pixel kColTitleBg = LICE_RGBA(20, 20, 22, 255);
const LICE_pixel kColCardBg = LICE_RGBA(44, 44, 48, 255);
const LICE_pixel kColCardSelBg = LICE_RGBA(48, 72, 64, 255);
const LICE_pixel kColCardBorder = LICE_RGBA(70, 70, 76, 255);
const LICE_pixel kColCardSelBorder = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColTabBg = LICE_RGBA(36, 36, 40, 255);
const LICE_pixel kColTabActiveBg = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColThumb = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColStripBg = LICE_RGBA(36, 36, 40, 255);
const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255);
const LICE_pixel kColRootMarker = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColWaveBg = LICE_RGBA(24, 24, 26, 255);
const LICE_pixel kColWaveform = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColStartMarker = LICE_RGBA(230, 200, 120, 255); // start point (amber)
const LICE_pixel kColLoopMarker = LICE_RGBA(120, 170, 230, 255); // loop start/end (blue)
const LICE_pixel kColLoopRegion = LICE_RGBA(120, 170, 230, 60); // loop span fill (faint)
const LICE_pixel kColZoneBar = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255);
const COLORREF kRgbText = RGB(210, 230, 220);
const COLORREF kRgbDim = RGB(140, 150, 146);
// ANSI path -- string literals must stay ASCII until the Phase L type kit lands.
void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col,
UINT fmt = DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX) {
HDC dc = bmp->getDC();
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, col);
RECT gr{r.left, r.top, r.right, r.bottom};
DrawTextA(dc, s, -1, &gr, fmt | DT_END_ELLIPSIS);
}
void drawTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
drawText(bmp, r, s, col, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
}
// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the
// common DAW convention REAPER uses).
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);
}
// Draw a mono peak envelope centered vertically in `r` (mirror of bank_panel::drawThumbnail,
// single channel). Each bin is a vertical line from its min to its max about the midline.
void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) {
if (r.width() <= 0 || r.height() <= 0 || env.empty() || env[0].empty()) return;
const ChannelEnvelope& ch = env[0];
const int mid = r.top + r.height() / 2;
const int halfH = r.height() / 2;
const int bins = static_cast<int>(ch.size());
for (int x = 0; x < r.width() && x < bins; ++x) {
const MinMax& mm = ch[static_cast<std::size_t>(x)];
const int yTop = mid - static_cast<int>(mm.max * halfH);
const int yBot = mid - static_cast<int>(mm.min * halfH);
LICE_Line(bmp, r.left + x, yTop, r.left + x, yBot, kColThumb, 1.0f, 0, false);
}
}
// A display name for a bank sample id from the snapshotted list ("?" if the id no longer
// resolves — e.g. a zone naming a deleted sample).
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) {
for (const SampleChoice& c : samples) {
if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
}
return "?";
}
// The bin count a card's thumbnail is computed at: the card thumbnail width, so one bin
// per horizontal pixel.
int thumbBins(const BrowserLayout& layout) {
return (std::max)(1, cardThumbnailRect(layout, 0).width());
}
#endif
} // namespace
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) {
// Default view size — sized to show a couple of card rows + the setup strip.
ViewRect r(0, 0, 560, 400);
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();
map_ = processor_->performanceMap();
channelMode_ = processor_->channelMode();
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
// 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() {
visible_.clear();
for (const SampleChoice& s : samples_) {
if (activeFilterBankId_.empty() || s.bankId == activeFilterBankId_)
visible_.push_back(s);
}
}
#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();
}
}
#endif // _WIN32
void ReaSamplerEditor::commitAndReload() {
// UI thread only. Publish the edited selection + zones to the processor, then rebuild
// the instrument off the audio thread (reloadFromBank bakes them into the live Keymap).
if (!processor_) return;
processor_->setSelectedSampleId(selectedId_);
processor_->setPerformanceMap(map_);
processor_->reloadFromBank();
#ifdef _WIN32
invalidate();
#endif
}
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); the override lives in map_.
if (processor_) {
auto banksJson =
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
if (banksJson) {
if (auto sel = selectSample(*banksJson, selectedId_)) {
if (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;
}
void 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.
SampleLoop loop;
loop.hasLoop = m.hasLoop;
loop.start = m.loopStart;
loop.end = m.loopEnd;
bool found = false;
for (PerformanceZone& z : map_.zones) {
if (z.sampleId == selectedId_) {
z.loopOverride = loop;
z.startPoint = m.start;
found = true;
break;
}
}
if (!found) {
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
z.loopOverride = loop;
z.startPoint = m.start;
map_.zones.push_back(z);
}
}
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()) {
const std::string projectDir = processor_->bridge().activeProjectDir();
const std::string abs = resolveBankFile(projectDir, relativePath);
std::vector<std::uint8_t> bytes;
std::ifstream f(abs, std::ios::binary | std::ios::ate);
if (f) {
const std::streamoff size = f.tellg();
if (size > 0) {
f.seekg(0, std::ios::beg);
bytes.resize(static_cast<std::size_t>(size));
if (!f.read(reinterpret_cast<char*>(bytes.data()), size)) bytes.clear();
}
}
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) {
const std::string key = sampleId + "|" + std::to_string(binCount);
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()) {
env = computeEnvelope(mono, 1, mono.size(),
static_cast<std::size_t>((std::max)(1, binCount)));
}
auto ins = thumbCache_.emplace(key, std::move(env));
return ins.first->second;
}
ReaSamplerEditor::~ReaSamplerEditor() {
#ifdef _WIN32
if (childHwnd_) {
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
#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;
}
#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;
}
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));
// 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;
}
// The client bands: title (top), toggle (below title), then the mode content. In the
// browser view the content is the browser grid on top of the single-capture setup band
// (when a capture is picked); in the zones view the content is the zones strip + list.
namespace {
struct EditorBands {
Rect title;
Rect toggleBrowser; // left half of the toggle band
Rect toggleZones; // right half
Rect content; // below the toggle band: the mode's own area
};
EditorBands computeBands(int w, int h) {
EditorBands b;
const int titleH = (std::min)(kTitleHeight, h);
b.title = Rect{0, 0, w, titleH};
const int toggleTop = titleH;
const int toggleBot = (std::min)(h, toggleTop + kToggleHeight);
b.toggleBrowser = Rect{0, toggleTop, w / 2, toggleBot};
b.toggleZones = Rect{w / 2, toggleTop, w, toggleBot};
b.content = Rect{0, toggleBot, w, h};
return b;
}
// The keyboard strip rectangle inside the setup area (single-capture root-drag face).
// `area` is the full setup Rect; the strip is anchored at the bottom with an 8px horizontal
// pad. All three call sites (paintSetup, onMouseDown, onMouseMove) use this single formula.
Rect setupStripArea(const Rect& area) {
constexpr int pad = 8;
const int stripTop = area.bottom - kStripBandHeight;
return Rect{area.left + pad, stripTop, area.right - pad, area.bottom - 4};
}
// The S11 waveform rectangle inside the setup area: a band above the keyboard strip, below the
// header/hint labels. `area` is the full setup Rect; the waveform is padded 8px horizontally and
// anchored above the strip band. All call sites (paintSetup, onMouseDown, onMouseMove) use this
// single formula so the draw and the hit-test never drift.
Rect setupWaveformArea(const Rect& area) {
constexpr int pad = 8;
const int waveBottom = area.bottom - kStripBandHeight - 6; // 6px gap above the strip
const int waveTop = waveBottom - kWaveformHeight;
return Rect{area.left + pad, waveTop, area.right - pad, waveBottom};
}
// The keyboard strip rectangle inside the Zones panel content area. `bands.content` is the
// mode-content Rect; the strip sits below the "+ Add Zone" affordance (top+4, height 20)
// with a 12px gap, padded 8px horizontally. All three call sites (paintZones, onMouseDown,
// onMouseMove) use this single formula — the inline arithmetic in onMouseMove was the drift.
Rect zonesStripArea(const EditorBands& bands) {
constexpr int pad = 8;
const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12
return Rect{bands.content.left + pad, stripTop, bands.content.right - pad,
stripTop + kStripBandHeight};
}
// The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's
// header row (same y as the sample-name header, so it reads as "this capture's output mode").
// `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide,
// kSegH tall, side by side. Kept to a small fenced block (S11 owns the waveform region).
constexpr int kChanSegW = 52;
constexpr int kChanSegH = 18;
struct ChannelToggleRects { Rect mono; Rect stereo; };
ChannelToggleRects channelToggleRects(const Rect& area) {
constexpr int pad = 8;
const int top = area.top + 4;
const int right = area.right - pad;
const Rect stereo{right - kChanSegW, top, right, top + kChanSegH};
const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH};
return {mono, stereo};
}
} // 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, kColBackground);
const EditorBands bands = computeBands(w, h);
// Title band: product name + live readout.
LICE_FillRect(&bmp, bands.title.left, bands.title.top, bands.title.width(),
bands.title.height(), kColTitleBg, 1.0f, 0);
std::string title = reasampler::vstPluginName(); // channel-derived (S18)
if (processor_ && processor_->bridge().isConnected()) {
if (samples_.empty()) title += " [bank empty]";
else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]";
else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
else title += " [" + sampleLabel(samples_, selectedId_) + "]";
} else {
title += " [host: no bridge]";
}
Rect titleText{bands.title.left + 8, bands.title.top, bands.title.right - 8,
bands.title.bottom};
drawText(&bmp, titleText, title.c_str(), kRgbText);
// Toggle band: Browser | Zones.
const bool inZones = (view_ == View::kZones);
LICE_FillRect(&bmp, bands.toggleBrowser.left, bands.toggleBrowser.top,
bands.toggleBrowser.width(), bands.toggleBrowser.height(),
inZones ? kColTabBg : kColTabActiveBg, 1.0f, 0);
LICE_FillRect(&bmp, bands.toggleZones.left, bands.toggleZones.top,
bands.toggleZones.width(), bands.toggleZones.height(),
inZones ? kColTabActiveBg : kColTabBg, 1.0f, 0);
drawTextCentered(&bmp, bands.toggleBrowser, "Browser", kRgbText);
drawTextCentered(&bmp, bands.toggleZones, "Zones", kRgbText);
if (view_ == View::kZones) {
paintZones(&bmp, w, h);
} else {
paintBrowser(&bmp, w, h);
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}
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.";
drawTextCentered(bmp, area, msg, kRgbDim);
}
void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
const EditorBands bands = computeBands(w, h);
// When a capture is picked, the setup band takes the bottom; the browser gets the rest.
const bool havePick = !selectedId_.empty();
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
: bands.content.bottom;
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
// The browser tabs + card grid, laid out by the pure module over the browser sub-area.
// capture_browser lays out from (0,0); offset the draw by browserArea's origin.
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
const int ox = browserArea.left;
const int oy = browserArea.top;
// Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights.
const int tabCount = static_cast<int>(banks_.size()) + 1;
for (int i = 0; i < tabCount; ++i) {
Rect t = filterTabRect(bl, tabCount, i);
t = Rect{t.left + ox, t.top + 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_);
LICE_FillRect(bmp, t.left, t.top, t.width(), t.height(),
active ? kColTabActiveBg : kColTabBg, 1.0f, 0);
drawTextCentered(bmp, t, label.c_str(), kRgbText);
}
// Cards: one per visible sample. Clip at the browser area bottom (scroll is S12).
const int bins = thumbBins(bl);
for (int i = 0; i < static_cast<int>(visible_.size()); ++i) {
Rect content = cardContentRect(bl, i);
if (content.top + oy >= browserArea.bottom) break; // past the visible grid
Rect thumb = cardThumbnailRect(bl, i);
Rect labelR = cardLabelRect(bl, i);
content = Rect{content.left + ox, content.top + oy, content.right + ox, content.bottom + oy};
thumb = Rect{thumb.left + ox, thumb.top + oy, thumb.right + ox, thumb.bottom + oy};
labelR = Rect{labelR.left + ox, labelR.top + oy, labelR.right + ox, labelR.bottom + oy};
const SampleChoice& s = visible_[static_cast<std::size_t>(i)];
const bool sel = (s.id == selectedId_);
LICE_FillRect(bmp, content.left, content.top, content.width(), content.height(),
sel ? kColCardSelBg : kColCardBg, 1.0f, 0);
LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1,
sel ? kColCardSelBorder : kColCardBorder, 1.0f, 0);
drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins));
// Name + root/key badge under the thumbnail.
std::string caption = s.displayName.empty() ? s.id : s.displayName;
Rect nameR{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2};
Rect badgeR{labelR.left + 3, nameR.bottom, labelR.right - 3, labelR.bottom};
drawText(bmp, nameR, caption.c_str(), kRgbText);
std::string badge;
if (s.rootNote) badge = "root " + noteLabel(*s.rootNote);
else if (s.key) badge = *s.key;
else badge = "root -";
drawText(bmp, badgeR, badge.c_str(), kRgbDim);
}
if (havePick) {
paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom});
} else if (visible_.empty()) {
paintEmptyState(bmp, browserArea);
}
}
void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) {
// The guided single-capture setup: the picked capture's name + root/level, and a
// keyboard strip with its root marker (drag to set root).
LICE_FillRect(bmp, area.left, area.top, area.width(), area.height(),
kColTitleBg, 1.0f, 0);
// Effective root: the picked sample's rootNote intrinsic (or middle C when unset).
// Read from samples_ (the full unfiltered list) so a bank-filter that hides the
// picked sample's bank doesn't mask its intrinsic root with the C4 default.
int root = 60;
for (const SampleChoice& s : samples_) {
if (s.id == selectedId_ && s.rootNote) root = *s.rootNote;
}
// If a matching one-zone override exists (opt-in from Zones), prefer it as the shown root.
for (const PerformanceZone& z : map_.zones) {
if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride;
}
const int pad = 8;
// The mono/stereo toggle sits at the right of the header row; keep the name text clear of it.
const ChannelToggleRects chan = channelToggleRects(area);
Rect headerR{area.left + pad, area.top + 4, chan.mono.left - 8, area.top + 22};
std::string header = sampleLabel(samples_, selectedId_) + " root " + noteLabel(root);
drawText(bmp, headerR, header.c_str(), kRgbText);
// S7 mono | stereo output-mode toggle. The active segment highlights (kColTabActiveBg),
// the inactive is kColTabBg — the same visual grammar as the Browser/Zones toggle.
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
LICE_FillRect(bmp, chan.mono.left, chan.mono.top, chan.mono.width(), chan.mono.height(),
isStereo ? kColTabBg : kColTabActiveBg, 1.0f, 0);
LICE_FillRect(bmp, chan.stereo.left, chan.stereo.top, chan.stereo.width(),
chan.stereo.height(), isStereo ? kColTabActiveBg : kColTabBg, 1.0f, 0);
drawTextCentered(bmp, chan.mono, "Mono", kRgbText);
drawTextCentered(bmp, chan.stereo, "Stereo", kRgbText);
Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16};
drawText(bmp, hintR,
"Drag the waveform markers to set start + loop; drag the keyboard to set root.",
kRgbDim);
// --- S11 waveform surface: the picked capture's envelope + draggable markers ----------
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
const Rect waveArea = setupWaveformArea(area);
LICE_FillRect(bmp, waveArea.left, waveArea.top, waveArea.width(), waveArea.height(),
kColWaveBg, 1.0f, 0);
if (frames > 0 && waveArea.width() > 0) {
// Envelope at one bin per pixel (full-res view of the decoded PCM, S10 read-only view
// reused). computeEnvelope over the cached mono frames — no new decode.
const int bins = (std::max)(1, waveArea.width());
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast<std::size_t>(bins));
drawEnvelope(bmp, waveArea, env); // reuses the thumbnail envelope draw (kColThumb)
const SetupMarkers m = pickedMarkers(frames);
// Faint loop-region fill between the loop markers (only when a loop is set).
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.top, rx - lx, waveArea.height(),
kColLoopRegion, 1.0f, 0);
}
}
// The three markers: start (amber), loop start + loop end (blue). Drawn as 2px vertical
// lines the full waveform height. Loop markers dim when no loop is set (the "no loop"
// state — draggable to CREATE one).
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const LICE_pixel markerCols[3] = {kColStartMarker, kColLoopMarker, kColLoopMarker};
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.top, 2, waveArea.height(), markerCols[i],
alpha, 0);
}
} else {
drawTextCentered(bmp, waveArea, "(decoding...)", kRgbDim);
}
// Keyboard strip with the root marker.
const Rect stripArea = setupStripArea(area);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int sx = stripArea.left;
const int sy = stripArea.top;
LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(),
kColStripBg, 1.0f, 0);
// Faint per-octave key ticks for orientation.
for (int n = 0; n <= 127; n += 12) {
Rect k = keyRect(sl, n);
LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey,
1.0f, 0, false);
}
Rect marker = rootMarkerRect(sl, root);
LICE_FillRect(bmp, marker.left + sx, sy, (std::max)(2, marker.width()), stripArea.height(),
kColRootMarker, 1.0f, 0);
}
void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) {
const EditorBands bands = computeBands(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{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96,
bands.content.top + 4 + 20};
LICE_FillRect(bmp, addR.left, addR.top, addR.width(), addR.height(), kColCardBg, 1.0f, 0);
LICE_DrawRect(bmp, addR.left, addR.top, addR.width() - 1, addR.height() - 1,
kColCardSelBorder, 1.0f, 0);
drawTextCentered(bmp, addR, "+ Add Zone", kRgbText);
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
if (selectedZone_ >= 0) {
LICE_FillRect(bmp, delR.left, delR.top, delR.width(), delR.height(), kColCardBg, 1.0f, 0);
LICE_DrawRect(bmp, delR.left, delR.top, delR.width() - 1, delR.height() - 1,
kColCardSelBorder, 1.0f, 0);
drawTextCentered(bmp, delR, "Delete", kRgbText);
}
// The zones strip.
const Rect stripArea = zonesStripArea(bands);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int sx = stripArea.left;
const int sy = stripArea.top;
LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(),
kColStripBg, 1.0f, 0);
for (int n = 0; n <= 127; n += 12) {
Rect k = keyRect(sl, n);
LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey,
1.0f, 0, false);
}
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 bool sel = (i == selectedZone_);
LICE_FillRect(bmp, bar.left + sx, sy, (std::max)(2, bar.width()), stripArea.height(),
sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0);
}
// A one-line legend of the selected zone below the strip.
Rect infoR{stripArea.left, stripArea.bottom + 8, stripArea.right, stripArea.bottom + 26};
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
std::string info = sampleLabel(samples_, z.sampleId) + " " + noteLabel(z.lowNote) +
" - " + noteLabel(z.highNote) + " root " +
(z.rootOverride ? noteLabel(*z.rootOverride) + "*" : std::string("(bank)"));
drawText(bmp, infoR, info.c_str(), kRgbText);
} else if (map_.zones.empty()) {
drawText(bmp, infoR,
"No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim);
}
}
// --- 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;
const EditorBands bands = computeBands(w, h);
// Toggle band: switch views.
if (contains(bands.toggleBrowser, x, y)) { view_ = View::kBrowser; invalidate(); return; }
if (contains(bands.toggleZones, x, y)) { view_ = View::kZones; invalidate(); return; }
if (view_ == View::kBrowser) {
const bool havePick = !selectedId_.empty();
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
: bands.content.bottom;
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
const int bx = x - browserArea.left;
const int by = y - browserArea.top;
// Filter tabs.
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;
}
// Cards: pick a capture -> load it (this is the whole time-to-first-note gesture).
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by);
if (card >= 0) {
selectedId_ = visible_[static_cast<std::size_t>(card)].id;
commitAndReload(); // publishes the pick + reloads; process() plays it repitched
return;
}
// The setup band: the mono/stereo toggle (header row), the S11 waveform markers,
// then the root-marker strip.
if (havePick) {
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
// S7: a click on a channel-mode segment sets the instance mode (setChannelMode
// re-negotiates the bus + reloads; a no-op set for the already-active mode is ignored
// by the processor). Snapshot the new mode locally so the paint reflects it at once.
const ChannelToggleRects chan = channelToggleRects(area);
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;
}
// S11 waveform markers: grab start / loop-start / loop-end to drag. Hit-test the
// waveform band first (it sits above the keyboard strip). markerAtPoint resolves
// which marker under the grab; a miss falls through to the keyboard strip.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
if (frames > 0) {
const Rect waveArea = setupWaveformArea(area);
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; // no immediate set — the marker only moves once the cursor drags
}
}
// The setup strip: grab the root marker (drag to set the picked capture's root).
const Rect stripArea = setupStripArea(area);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top);
if (note >= 0) {
drag_ = DragKind::kRootMarker;
dragStartX_ = x;
dragStartRoot_ = note;
dragStartMap_ = map_;
// A click sets the root immediately (drag then refines); the override lives on
// a one-zone map entry for the picked capture (D-B, never written to the bank).
onMouseMove(x, y); // apply the click position as the first delta==0 set
return;
}
}
return;
}
// Zones view.
const int pad = 8;
Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96,
bands.content.top + 4 + 20};
if (contains(addR, x, y)) {
// Add a full-keyboard 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, select it rather than appending a duplicate (mirrors the upsert the
// root-marker drag path already performs, preventing overlapping identical zones).
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;
}
}
PerformanceZone z;
z.sampleId = seed;
z.lowNote = 0;
z.highNote = 127;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
commitAndReload();
return;
}
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
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(bands);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int lx = x - stripArea.left;
const int ly = y - stripArea.top;
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();
}
}
}
void ReaSamplerEditor::onMouseMove(int x, int y) {
if (drag_ == DragKind::kNone) return;
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
const EditorBands bands = computeBands(w, h);
const int dx = x - dragStartX_;
if (drag_ == DragKind::kRootMarker) {
// The single-capture root strip lives in the setup band.
const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight);
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
const Rect stripArea = setupStripArea(area);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int note = resolveDragNote(sl, dragStartRoot_, dx);
// The performance map is the ONLY D-B override vehicle (rootOverride lives on a zone),
// so setting the single capture's root materializes a full-keyboard zone carrying the
// override. This plays identically to the un-zoned single-capture path (one chromatic
// zone over the whole keyboard) and round-trips through the v3 component state; the
// zone becomes visible if the user opens the Zones panel. Upsert by the picked id so a
// repeated drag edits the same zone rather than stacking duplicates.
bool found = false;
for (PerformanceZone& z : map_.zones) {
if (z.sampleId == selectedId_) { z.rootOverride = note; found = true; break; }
}
if (!found) {
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
z.rootOverride = note;
map_.zones.push_back(z);
}
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
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 int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight);
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
const Rect waveArea = setupWaveformArea(area);
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.
upsertPickedOverride(m);
invalidate();
return;
}
// Zone edits: recompute the grabbed field(s) against the pure resolver, live.
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
const Rect stripArea = zonesStripArea(bands);
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*/) {
if (drag_ == DragKind::kNone) return;
drag_ = DragKind::kNone;
// One coherent edit lands on release: publish the in-flight map + reload off-thread.
commitAndReload();
}
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
self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
}
return 0;
case WM_MOUSEMOVE:
if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
return 0;
case WM_LBUTTONUP:
if (self) {
self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
ReleaseCapture();
}
return 0;
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 && self->drag_ != DragKind::kNone) {
self->map_ = self->dragStartMap_;
self->drag_ = DragKind::kNone;
self->invalidate();
}
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