1085 lines
50 KiB
C++
1085 lines
50 KiB
C++
// 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 "reasampler_vst.h" // kPluginName (the editor title band)
|
||
#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";
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
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));
|
||
}
|
||
}
|
||
|
||
void ReaSamplerEditor::removedFromParent() {
|
||
if (childHwnd_) {
|
||
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 = kPluginName;
|
||
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_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
|