S5 Tier-1: zoned keymap editor + performance-map playback/persistence

Zone editor in the IPlugView LICE surface, zoned resolution built off-thread into the
LoadedInstrument keymap with Tier-0 fallback, performance map in VST3 component state
with v1 back-compat. Pure resolve/build/serialize + geometry with CTest coverage.
This commit is contained in:
2026-07-26 17:28:15 -04:00
parent 6ae843345c
commit 2356958930
10 changed files with 1063 additions and 63 deletions
+164 -22
View File
@@ -38,6 +38,9 @@ const LICE_pixel kColBtnHitBg = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColBtnBorder = LICE_RGBA(120, 200, 160, 255);
const COLORREF kRgbText = RGB(210, 230, 220);
const LICE_pixel kColZoneSelBg = LICE_RGBA(48, 72, 64, 255);
const LICE_pixel kColCtrlBg = LICE_RGBA(60, 60, 66, 255);
void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
// LICE has no built-in font handle here; use GDI text into the bitmap DC, matching
// bank_panel's drawCenteredText approach (SetTextColor + DrawText on getDC()).
@@ -47,6 +50,26 @@ void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
RECT gr{r.left, r.top, r.right, r.bottom};
DrawTextA(dc, s, -1, &gr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
}
void drawTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
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, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
}
// Clamp a MIDI note to [0, 127].
int clampNote(int n) { return n < 0 ? 0 : (n > 127 ? 127 : n); }
// A short display name for a bank sample id, from the snapshotted list (id if unnamed,
// "?" if the id no longer resolves — e.g. a stale zone).
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 "?"; // stale: id not in the live bank
}
#endif
} // namespace
@@ -62,11 +85,26 @@ void ReaSamplerEditor::refreshSampleList() {
if (!processor_) {
samples_.clear();
selectedId_.clear();
map_.zones.clear();
selectedZone_ = -1;
return;
}
auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
selectedId_ = processor_->selectedSampleId();
map_ = processor_->performanceMap();
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
}
void ReaSamplerEditor::commitMapAndReload() {
// UI thread only. Publish the edited map to the processor, then rebuild the instrument
// off the audio thread (reloadFromBank bakes the zones into the live Keymap).
if (!processor_) return;
processor_->setPerformanceMap(map_);
processor_->reloadFromBank();
#ifdef _WIN32
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
#endif
}
ReaSamplerEditor::~ReaSamplerEditor() {
@@ -160,28 +198,33 @@ void ReaSamplerEditor::paint(HDC hdc) {
LICE_SysBitmap bmp(w, h);
LICE_Clear(&bmp, kColBackground);
const EditorLayout layout = layoutEditor(w, h);
const KeymapEditorLayout layout = layoutKeymapEditor(w, h);
const EditorLayout& base = layout.base;
// Title band: the plugin name + whether a live bank is linked.
LICE_FillRect(&bmp, layout.titleBar.left, layout.titleBar.top,
layout.titleBar.width(), layout.titleBar.height(), kColTitleBg, 1.0f,
0);
// Title band: the plugin name + a live-state / mode readout.
LICE_FillRect(&bmp, base.titleBar.left, base.titleBar.top, base.titleBar.width(),
base.titleBar.height(), kColTitleBg, 1.0f, 0);
std::string title = "ReaSampler Instrument";
if (processor_ && processor_->bridge().isConnected()) {
title += samples_.empty() ? " [bank: empty]" : " [pick a sample]";
if (samples_.empty()) {
title += " [bank: empty]";
} else if (map_.zones.empty()) {
title += " [Tier 0: pick a sample - Add Zone for a keymap]";
} else {
title += " [keymap: " + std::to_string(map_.zones.size()) + " zone(s)]";
}
} else {
title += " [host: no bridge]";
}
Rect titleText{layout.titleBar.left + 8, layout.titleBar.top,
layout.titleBar.right - 8, layout.titleBar.bottom};
Rect titleText{base.titleBar.left + 8, base.titleBar.top, base.titleBar.right - 8,
base.titleBar.bottom};
drawText(&bmp, titleText, title.c_str(), kRgbText);
// Sample list: one row per bank sample, the selected one highlighted. Clip drawing
// to rows that fall within the canvas (sampleRowRect computes all; we skip off-screen
// ones so a huge bank doesn't waste paint).
// LEFT column: the bank-sample list. The selected id (fallback + "sample to add" for
// a new zone) is highlighted. Clip rows past the visible list.
for (int i = 0; i < static_cast<int>(samples_.size()); ++i) {
const Rect row = sampleRowRect(layout, i);
if (row.top >= layout.canvas.bottom) break; // past the visible area
const Rect row = keymapSampleRowRect(layout, i);
if (row.top >= layout.sampleList.bottom) break;
const bool sel = !selectedId_.empty() && samples_[i].id == selectedId_;
LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(),
sel ? kColBtnHitBg : kColBtnBg, 1.0f, 0);
@@ -195,6 +238,44 @@ void ReaSamplerEditor::paint(HDC hdc) {
kRgbText);
}
// RIGHT column: the zone panel. "Add Zone" button on top, then one row per zone.
LICE_FillRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top,
layout.addZoneButton.width(), layout.addZoneButton.height(), kColBtnBg,
1.0f, 0);
LICE_DrawRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top,
layout.addZoneButton.width() - 1, layout.addZoneButton.height() - 1,
kColBtnBorder, 1.0f, 0);
drawTextCentered(&bmp, layout.addZoneButton, "+ Add Zone", kRgbText);
// The seven per-row controls, laid out left-to-right pinned to the right edge.
const char* kCtrlGlyphs[7] = {"-", "+", "-", "+", "-", "+", "x"};
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
const Rect row = zoneRowRect(layout, i);
if (row.top >= layout.zoneRowArea.bottom) break; // past the visible panel
const bool sel = (i == selectedZone_);
LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(),
sel ? kColZoneSelBg : kColBtnBg, 1.0f, 0);
const PerformanceZone& z = map_.zones[i];
std::string label = sampleLabel(samples_, z.sampleId);
label += " " + std::to_string(z.lowNote) + "-" + std::to_string(z.highNote);
label += " r" + (z.rootOverride ? std::to_string(*z.rootOverride) + "*"
: std::string("(bank)"));
// Label area stops where the control block begins (7 * ctrl width from the right).
const int ctrlBlockLeft = row.right - 7 * kZoneCtrlWidth;
Rect textR{row.left + 6, row.top, ctrlBlockLeft - 4, row.bottom};
drawText(&bmp, textR, label.c_str(), kRgbText);
// Draw the 7 mini-buttons.
for (int s = 0; s < 7; ++s) {
const int bx = ctrlBlockLeft + s * kZoneCtrlWidth;
Rect cell{bx, row.top + 2, bx + kZoneCtrlWidth - 1, row.bottom - 2};
LICE_FillRect(&bmp, cell.left, cell.top, cell.width(), cell.height(),
kColCtrlBg, 1.0f, 0);
drawTextCentered(&bmp, cell, kCtrlGlyphs[s], kRgbText);
}
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}
@@ -202,16 +283,77 @@ void ReaSamplerEditor::onClick(int x, int y) {
if (!processor_) return;
RECT cr{};
GetClientRect(childHwnd_, &cr);
const EditorLayout layout = layoutEditor(cr.right - cr.left, cr.bottom - cr.top);
const int row = sampleRowHitTest(layout, static_cast<int>(samples_.size()), x, y);
if (row < 0) return;
const KeymapEditorLayout layout =
layoutKeymapEditor(cr.right - cr.left, cr.bottom - cr.top);
// Select this sample and rebuild the instrument OFF the audio thread (this WM_
// handler runs on the UI thread). reloadFromBank reads the id we just set.
processor_->setSelectedSampleId(samples_[row].id);
processor_->reloadFromBank();
selectedId_ = samples_[row].id;
InvalidateRect(childHwnd_, nullptr, FALSE);
// 1. Left list: pick the Tier-0 fallback / the sample a new zone will use.
const int row =
keymapSampleRowHitTest(layout, static_cast<int>(samples_.size()), x, y);
if (row >= 0) {
processor_->setSelectedSampleId(samples_[row].id);
selectedId_ = samples_[row].id;
// A fallback change only affects playback when the map is empty; reload so the
// Tier-0 case updates immediately.
processor_->reloadFromBank();
InvalidateRect(childHwnd_, nullptr, FALSE);
return;
}
// 2. "Add Zone": append a full-keyboard zone for the currently-selected sample (root
// from the bank intrinsic — no override until the user nudges it).
if (addZoneHitTest(layout, x, y)) {
if (selectedId_.empty()) return; // nothing selected to add
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
commitMapAndReload();
return;
}
// 3. Zone rows: select a zone, nudge its range/root, or delete it.
const ZoneHit hit =
zoneHitTest(layout, static_cast<int>(map_.zones.size()), x, y);
if (hit.zoneIndex < 0) return;
selectedZone_ = hit.zoneIndex;
if (hit.field == ZoneField::kZoneNone) {
InvalidateRect(childHwnd_, nullptr, FALSE); // select only, no reload
return;
}
if (hit.field == ZoneField::kDelete) {
map_.zones.erase(map_.zones.begin() + hit.zoneIndex);
selectedZone_ = -1;
commitMapAndReload();
return;
}
PerformanceZone& z = map_.zones[hit.zoneIndex];
switch (hit.field) {
case ZoneField::kLowDown: z.lowNote = clampNote(z.lowNote - 1); break;
case ZoneField::kLowUp: z.lowNote = clampNote(z.lowNote + 1); break;
case ZoneField::kHighDown: z.highNote = clampNote(z.highNote - 1); break;
case ZoneField::kHighUp: z.highNote = clampNote(z.highNote + 1); break;
case ZoneField::kRootDown: {
const int base = z.rootOverride ? *z.rootOverride : 60;
z.rootOverride = clampNote(base - 1); // first nudge establishes the override
break;
}
case ZoneField::kRootUp: {
const int base = z.rootOverride ? *z.rootOverride : 60;
z.rootOverride = clampNote(base + 1);
break;
}
default: break; // kZoneNone/kDelete handled above
}
// Keep low <= high after a range nudge (clamp the moved edge against its partner).
if (z.lowNote > z.highNote) {
if (hit.field == ZoneField::kLowUp) z.lowNote = z.highNote;
else if (hit.field == ZoneField::kHighDown) z.highNote = z.lowNote;
}
commitMapAndReload();
}
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,