Merge ps-w4-tier1-keymap: S5 Tier 1 — zoned keymap editor + performance-map persistence
This commit is contained in:
@@ -75,4 +75,90 @@ int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
|
||||
return index;
|
||||
}
|
||||
|
||||
// --- Keymap editor -----------------------------------------------------------
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h) {
|
||||
KeymapEditorLayout out;
|
||||
out.base = layoutEditor(w, h);
|
||||
const Rect& canvas = out.base.canvas;
|
||||
|
||||
// Split the canvas vertically: the left column is the bank-sample list, the right
|
||||
// column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so
|
||||
// the split point never crosses the canvas edges.
|
||||
const int canvasW = std::max(0, canvas.width());
|
||||
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
|
||||
const int splitX = std::max(canvas.left, canvas.right - splitW);
|
||||
|
||||
out.sampleList = Rect{canvas.left, canvas.top, splitX, canvas.bottom};
|
||||
out.zonePanel = Rect{splitX, canvas.top, canvas.right, canvas.bottom};
|
||||
|
||||
// "Add Zone" button spans the top of the zone panel, clamped to its height.
|
||||
const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height()));
|
||||
out.addZoneButton =
|
||||
Rect{out.zonePanel.left, out.zonePanel.top, out.zonePanel.right,
|
||||
out.zonePanel.top + addH};
|
||||
|
||||
// Zone rows stack below the button.
|
||||
out.zoneRowArea = Rect{out.zonePanel.left, out.addZoneButton.bottom,
|
||||
out.zonePanel.right, out.zonePanel.bottom};
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.sampleList.top + index * kSampleRowHeight;
|
||||
return Rect{layout.sampleList.left, top, layout.sampleList.right,
|
||||
top + kSampleRowHeight};
|
||||
}
|
||||
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
const Rect& list = layout.sampleList;
|
||||
if (x < list.left || x >= list.right) return -1;
|
||||
if (y < list.top || y >= list.bottom) return -1;
|
||||
const int index = (y - list.top) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
const Rect r = keymapSampleRowRect(layout, index);
|
||||
if (y >= r.bottom) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.zoneRowArea.top + index * kZoneRowHeight;
|
||||
return Rect{layout.zoneRowArea.left, top, layout.zoneRowArea.right,
|
||||
top + kZoneRowHeight};
|
||||
}
|
||||
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) {
|
||||
if (zoneCount <= 0) return ZoneHit{};
|
||||
const Rect& area = layout.zoneRowArea;
|
||||
if (x < area.left || x >= area.right) return ZoneHit{};
|
||||
if (y < area.top || y >= area.bottom) return ZoneHit{};
|
||||
const int index = (y - area.top) / kZoneRowHeight;
|
||||
if (index < 0 || index >= zoneCount) return ZoneHit{};
|
||||
const Rect row = zoneRowRect(layout, index);
|
||||
if (y >= row.bottom) return ZoneHit{};
|
||||
|
||||
// Seven mini-buttons pinned to the right edge, right-to-left:
|
||||
// delete, root+, root-, high+, high-, low+, low-
|
||||
// Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select").
|
||||
// The fields laid out LEFT-TO-RIGHT in slot order 0..6.
|
||||
const ZoneField fields[7] = {
|
||||
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
|
||||
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
|
||||
ZoneField::kDelete,
|
||||
};
|
||||
const int slots = 7;
|
||||
const int ctrlBlockLeft = row.right - slots * kZoneCtrlWidth;
|
||||
if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select
|
||||
const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth;
|
||||
if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone};
|
||||
return ZoneHit{index, fields[slot]};
|
||||
}
|
||||
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) {
|
||||
return contains(layout.addZoneButton, x, y);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -75,4 +75,75 @@ Rect sampleRowRect(const EditorLayout& layout, int index);
|
||||
// outside the list (above the first row, past the last, or on the title bar). Pure.
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// --- Keymap editor (S5 Tier-1 UI) -------------------------------------------
|
||||
//
|
||||
// The Tier-1 editor splits the canvas into a LEFT bank-sample list (the same rows as
|
||||
// Tier 0, reused for the "sample to add / fallback pick") and a RIGHT zone panel listing
|
||||
// the performance map's zones. An "Add Zone" button sits at the top of the zone panel;
|
||||
// each zone row carries small nudge/delete controls so the user can set the range and
|
||||
// root note without a text field (LICE has no native numeric entry). All rectangle math
|
||||
// is here so the shell only draws + routes — the mirror of the sample-list split above.
|
||||
|
||||
// Fixed metrics for the zone panel, exposed so the shell and tests agree.
|
||||
inline constexpr int kZoneRowHeight = 24;
|
||||
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas
|
||||
inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button
|
||||
inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band height
|
||||
|
||||
// The keymap editor's regions, derived from the (w x h) client area. All clamp to the
|
||||
// canvas so a degenerate view yields in-bounds rects.
|
||||
struct KeymapEditorLayout {
|
||||
EditorLayout base; // title bar + canvas (the sample list uses base.canvas.left half)
|
||||
Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here)
|
||||
Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows
|
||||
Rect addZoneButton; // top of the zone panel
|
||||
Rect zoneRowArea; // below addZoneButton: where zone rows stack
|
||||
};
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h);
|
||||
|
||||
// The rectangle for bank-sample row `index` inside the LEFT sample list column of a
|
||||
// keymap layout. Same fixed height as the Tier-0 list; laid out top-down inside
|
||||
// sampleList. Negative index -> empty. Pure.
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// The bank-sample row a click lands on inside the left list, or -1 outside it. Pure.
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// The rectangle for zone row `index` inside the zone panel's zoneRowArea. Negative
|
||||
// index -> empty. Pure.
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// A zone row's interactive fields. The row is a horizontal strip: a label on the left,
|
||||
// then seven fixed-width mini-buttons on the right (left-to-right: low-, low+, high-, high+,
|
||||
// root-, root+, delete). kZoneNone means the click missed a control
|
||||
// (e.g. on the label) — the shell may still treat that as "select this zone".
|
||||
enum class ZoneField {
|
||||
kZoneNone,
|
||||
kLowDown,
|
||||
kLowUp,
|
||||
kHighDown,
|
||||
kHighUp,
|
||||
kRootDown,
|
||||
kRootUp,
|
||||
kDelete,
|
||||
};
|
||||
|
||||
// The result of hit-testing a click against the zone rows: which zone row (or -1) and
|
||||
// which field within it. A click on the "Add Zone" button is reported separately by
|
||||
// addZoneHitTest — this covers only the zone rows.
|
||||
struct ZoneHit {
|
||||
int zoneIndex = -1;
|
||||
ZoneField field = ZoneField::kZoneNone;
|
||||
};
|
||||
|
||||
// Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a
|
||||
// click outside every zone row. Within a row, the seven mini-buttons occupy fixed-width
|
||||
// slots on the right edge (left-to-right: low-, low+, high-, high+, root-, root+, delete);
|
||||
// a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure.
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y);
|
||||
|
||||
// True if (x, y) lands on the "Add Zone" button. Pure.
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
+161
-19
@@ -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.
|
||||
// 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);
|
||||
processor_->reloadFromBank();
|
||||
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,
|
||||
|
||||
@@ -54,7 +54,8 @@ private:
|
||||
#ifdef _WIN32
|
||||
// Draw the current surface into the child window's DC via a LICE bitmap.
|
||||
void paint(HDC hdc);
|
||||
// Route a client-space click: select the sample row under (x, y), if any.
|
||||
// Route a client-space click: bank-sample pick (left list), zone edit (right panel),
|
||||
// or the "Add Zone" button.
|
||||
void onClick(int x, int y);
|
||||
|
||||
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||
@@ -62,16 +63,28 @@ private:
|
||||
HWND childHwnd_ = nullptr;
|
||||
#endif
|
||||
|
||||
// Re-read the bank's sample list from the live bridge into `samples_`. Main/UI
|
||||
// thread only (reads ext-state); called on attach and after a selection reload.
|
||||
// Re-read the bank's sample list from the live bridge into `samples_`, and snapshot the
|
||||
// instrument's performance map. Main/UI thread only (reads ext-state); called on attach
|
||||
// and after any edit that reloads the instrument.
|
||||
void refreshSampleList();
|
||||
|
||||
// Push the edited performance map to the processor and rebuild the instrument OFF the
|
||||
// audio thread. UI thread only. Centralizes the "commit an edit" path so every zone
|
||||
// mutation reloads identically.
|
||||
void commitMapAndReload();
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
// The bank's samples, snapshotted for the current paint. Refreshed off the audio
|
||||
// thread; the paint just draws it.
|
||||
std::vector<SampleChoice> samples_;
|
||||
// The currently-selected sample id, mirrored for the paint's highlight.
|
||||
// The currently-selected sample id (Tier-0 fallback + the "sample to add" for a new
|
||||
// zone), mirrored for the paint's highlight.
|
||||
std::string selectedId_;
|
||||
// The instrument's performance map, snapshotted for edit + paint. Edits mutate this
|
||||
// copy then commit it to the processor via commitMapAndReload.
|
||||
PerformanceMap map_;
|
||||
// The zone row currently highlighted (for the paint); -1 = none.
|
||||
int selectedZone_ = -1;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "pluginterfaces/base/ibstream.h"
|
||||
@@ -15,7 +17,7 @@
|
||||
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "ext_keys.h" // kProjExtBanksKey (shared wire contract)
|
||||
#include "reasampler_editor.h"
|
||||
#include "sample_map.h" // selectSample, downmixToMono, buildTier0Keymap, state (de)ser
|
||||
#include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser
|
||||
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
|
||||
using namespace Steinberg;
|
||||
@@ -59,6 +61,28 @@ std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file
|
||||
// I/O — off-thread only), and downmix to the core's mono contract. Returns nullopt when
|
||||
// the path fails to resolve, the file is unreadable, the WAV is malformed, or the decode
|
||||
// yields no frames — the caller drops the zone (Tier 1) or plays silence (Tier 0). Shared
|
||||
// by the zoned build and the Tier-0 fallback so both decode identically.
|
||||
std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||
const std::string& relativePath) {
|
||||
const std::string abs = resolveBankFile(projectDir, relativePath);
|
||||
if (abs.empty()) return std::nullopt;
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid) return std::nullopt;
|
||||
std::vector<AudioSample> interleaved =
|
||||
extractFloatFrames(bytes, layout, 0, layout.frameCount());
|
||||
std::vector<AudioSample> mono = downmixToMono(interleaved, layout.channelCount);
|
||||
if (mono.empty()) return std::nullopt;
|
||||
DecodedZonePcm out;
|
||||
out.monoFrames = std::move(mono);
|
||||
out.sampleRate = static_cast<int>(layout.sampleRate);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
|
||||
@@ -115,23 +139,33 @@ tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Read the whole component-state blob (the selected sample id, versioned). The blob
|
||||
// is small; read in one shot into a growable buffer.
|
||||
// Read the whole component-state blob (the performance map, versioned). The blob is
|
||||
// small; read in one shot into a growable buffer.
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::uint8_t chunk[256];
|
||||
int32 got = 0;
|
||||
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
|
||||
bytes.insert(bytes.end(), chunk, chunk + got);
|
||||
}
|
||||
setSelectedSampleId(deserializeSelection(bytes));
|
||||
// Rebuild from the restored selection (off-thread — setState is a load-time call).
|
||||
// Component state IS the performance map (Tier 1, D-B). deserializePerformance lifts a
|
||||
// v1 (S4 single-selection) blob to a one-zone map, so already-saved Tier-0 instances
|
||||
// restore cleanly. When the restored map is empty, the instrument falls back to the
|
||||
// Tier-0 first-sample in reloadFromBank; if any single-zone restored map is present we
|
||||
// seed the fallback selection from it so the editor reflects the restored pick.
|
||||
const PerformanceMap map = deserializePerformance(bytes);
|
||||
setPerformanceMap(map);
|
||||
if (map.zones.size() == 1) setSelectedSampleId(map.zones.front().sampleId);
|
||||
// Rebuild from the restored state (off-thread — setState is a load-time call).
|
||||
reloadFromBank();
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
const std::vector<std::uint8_t> bytes = serializeSelection(selectedSampleId());
|
||||
// Persist the performance map (the instrument's own Tier-1 state, D-B — NEVER written
|
||||
// to the "reasampler" bank ext-state). An empty map serializes to just the version +
|
||||
// zero-count header (restores as empty -> Tier-0 fallback).
|
||||
const std::vector<std::uint8_t> bytes = serializePerformance(performanceMap());
|
||||
if (!bytes.empty()) {
|
||||
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
||||
static_cast<int32>(bytes.size()), nullptr);
|
||||
@@ -150,6 +184,16 @@ void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
|
||||
selectedSampleId_ = id;
|
||||
}
|
||||
|
||||
PerformanceMap ReaSamplerProcessor::performanceMap() {
|
||||
std::lock_guard<std::mutex> lock(performanceMutex_);
|
||||
return performanceMap_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
|
||||
std::lock_guard<std::mutex> lock(performanceMutex_);
|
||||
performanceMap_ = map;
|
||||
}
|
||||
|
||||
std::string ReaSamplerProcessor::reloadFromBank() {
|
||||
// OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so
|
||||
// the retired-slot free is single-writer. This mutex is NEVER taken on the audio
|
||||
@@ -170,35 +214,57 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
||||
std::unique_ptr<LoadedInstrument> built;
|
||||
|
||||
if (banksJson) {
|
||||
// 2. Pick the sample (shared bank_book JSON parse — NOT a second parser).
|
||||
// 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its
|
||||
// zones against the live bank (STALE ids drop cleanly), decode each zone's WAV
|
||||
// off-thread, and build the ZONED keymap. Each surviving zone plays its bank
|
||||
// sample repitched from its effective root note (override > bank intrinsic > C4).
|
||||
// A zone whose WAV fails to decode is dropped (not the whole map).
|
||||
const PerformanceMap map = performanceMap();
|
||||
Keymap km;
|
||||
bool haveKeymap = false;
|
||||
|
||||
if (!map.empty()) {
|
||||
const ResolvedPerformance resolved = resolvePerformance(*banksJson, map);
|
||||
if (!resolved.zones.empty()) {
|
||||
std::vector<DecodedZonePcm> decoded;
|
||||
std::vector<ResolvedZone> kept;
|
||||
decoded.reserve(resolved.zones.size());
|
||||
kept.reserve(resolved.zones.size());
|
||||
for (const ResolvedZone& rz : resolved.zones) {
|
||||
std::optional<DecodedZonePcm> pcm =
|
||||
decodeRelative(projectDir, rz.relativePath);
|
||||
if (!pcm) continue; // unreadable WAV -> drop this zone
|
||||
kept.push_back(rz);
|
||||
decoded.push_back(std::move(*pcm));
|
||||
}
|
||||
km = buildZonedKeymap(kept, decoded);
|
||||
haveKeymap = !km.zones.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tier-0 fallback: an empty (or fully-unresolvable) performance map plays the
|
||||
// selected fallback sample chromatically across the whole keyboard — preserving
|
||||
// the S4 "the bank plays" behavior for an un-authored instrument.
|
||||
if (!haveKeymap) {
|
||||
std::optional<SelectedSample> sel =
|
||||
selectSample(*banksJson, selectedSampleId());
|
||||
if (sel) {
|
||||
// 3. Resolve the project-relative WAV path the M4 way persist does, read +
|
||||
// decode it (file I/O off-thread), downmix to the core's mono contract.
|
||||
const std::string abs = resolveBankFile(projectDir, sel->relativePath);
|
||||
if (!abs.empty()) {
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (layout.valid) {
|
||||
const std::size_t frames = layout.frameCount();
|
||||
std::vector<AudioSample> interleaved =
|
||||
extractFloatFrames(bytes, layout, 0, frames);
|
||||
std::vector<AudioSample> mono =
|
||||
downmixToMono(interleaved, layout.channelCount);
|
||||
if (!mono.empty()) {
|
||||
Keymap km = buildTier0Keymap(
|
||||
std::move(mono),
|
||||
static_cast<int>(layout.sampleRate), sel->rootNote,
|
||||
sel->loop);
|
||||
built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen);
|
||||
std::optional<DecodedZonePcm> pcm =
|
||||
decodeRelative(projectDir, sel->relativePath);
|
||||
if (pcm) {
|
||||
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
|
||||
sel->rootNote, sel->loop);
|
||||
haveKeymap = true;
|
||||
// Record which id actually resolved so a first-sample fallback
|
||||
// (empty stored id) becomes the concrete selection.
|
||||
resolvedId = selectedSampleId();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (haveKeymap) {
|
||||
built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
|
||||
|
||||
#include "reaper_bridge.h"
|
||||
#include "sample_map.h" // PerformanceMap (the instrument's owned zoned keymap)
|
||||
#include "sampler_core.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
@@ -97,10 +98,18 @@ public:
|
||||
// editor borrows it (outlives the editor).
|
||||
ReaperBridge& bridge() { return bridge_; }
|
||||
// The current selection id (main/UI thread reads for the editor). Guarded by
|
||||
// selectionMutex_ — never touched on the audio thread.
|
||||
// selectionMutex_ — never touched on the audio thread. In Tier 1 the selection is the
|
||||
// Tier-0 FALLBACK sample (played chromatically when the performance map is empty); the
|
||||
// zoned map, when non-empty, supersedes it.
|
||||
std::string selectedSampleId();
|
||||
void setSelectedSampleId(const std::string& id);
|
||||
|
||||
// The performance map (Tier 1: the zoned keymap the instrument owns; D-B). Read/written
|
||||
// by the editor on the UI thread; snapshotted under performanceMutex_. NEVER read on the
|
||||
// audio thread — reloadFromBank bakes it into the LoadedInstrument's Keymap off-thread.
|
||||
PerformanceMap performanceMap();
|
||||
void setPerformanceMap(const PerformanceMap& map);
|
||||
|
||||
private:
|
||||
ReaperBridge bridge_;
|
||||
|
||||
@@ -139,11 +148,17 @@ private:
|
||||
std::vector<GraveyardEntry> graveyard_; // drained on reclaim + setActive(false) + terminate
|
||||
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
||||
|
||||
// The selected sample id (instance state). Off-thread only; a small mutex guards the
|
||||
// string against a getState/editor race. NOT read on the audio thread.
|
||||
// The selected sample id (Tier-0 fallback sample). Off-thread only; a small mutex
|
||||
// guards the string against a getState/editor race. NOT read on the audio thread.
|
||||
std::mutex selectionMutex_;
|
||||
std::string selectedSampleId_;
|
||||
|
||||
// The performance map (Tier 1: the instrument's owned zoned keymap). Off-thread only;
|
||||
// guarded against a getState/editor race. NOT read on the audio thread — reloadFromBank
|
||||
// bakes it into the LoadedInstrument's Keymap under the reload lock.
|
||||
std::mutex performanceMutex_;
|
||||
PerformanceMap performanceMap_;
|
||||
|
||||
// Latched from setupProcessing so setActive/reload can size against it. Read
|
||||
// off-thread only.
|
||||
double sampleRate_ = 44100.0;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
#include "sample_map.h"
|
||||
|
||||
#include <algorithm> // std::min
|
||||
#include <cstring> // std::memcpy
|
||||
#include <utility> // std::move
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
@@ -103,6 +105,168 @@ Keymap buildTier0Keymap(std::vector<AudioSample> monoFrames, int sampleRate,
|
||||
return Keymap::singleSampleChromatic(std::move(data));
|
||||
}
|
||||
|
||||
// --- Performance map ---------------------------------------------------------
|
||||
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0)
|
||||
if (banksJson.empty()) return out; // no bank -> nothing resolves
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out; // malformed -> nothing (never throw)
|
||||
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// Look the id up across every bank (pool + named) — a sample lives in exactly
|
||||
// one bank, so first hit wins.
|
||||
const Sample* found = nullptr;
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(z.sampleId)) {
|
||||
found = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune).
|
||||
out.droppedSampleIds.push_back(z.sampleId);
|
||||
continue;
|
||||
}
|
||||
ResolvedZone rz;
|
||||
rz.relativePath = found->relativePath;
|
||||
rz.lowNote = z.lowNote;
|
||||
rz.highNote = z.highNote;
|
||||
// Effective root: override beats bank intrinsic beats middle-C default.
|
||||
rz.rootNote = z.rootOverride ? *z.rootOverride
|
||||
: (found->rootNote ? *found->rootNote : 60);
|
||||
rz.loop = loopFromSample(*found);
|
||||
out.zones.push_back(std::move(rz));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded) {
|
||||
Keymap km;
|
||||
const std::size_t n = std::min(zones.size(), decoded.size());
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
// An unreadable/empty WAV drops just this zone (not the whole map).
|
||||
if (decoded[i].monoFrames.empty()) continue;
|
||||
SampleData data;
|
||||
data.frames = decoded[i].monoFrames;
|
||||
data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100;
|
||||
data.rootNote = zones[i].rootNote;
|
||||
data.loop = zones[i].loop;
|
||||
const std::size_t sampleIndex = km.samples.size();
|
||||
km.samples.push_back(std::move(data));
|
||||
KeyZone zone;
|
||||
zone.lowNote = zones[i].lowNote;
|
||||
zone.highNote = zones[i].highNote;
|
||||
zone.rootNote = zones[i].rootNote;
|
||||
zone.sampleIndex = sampleIndex;
|
||||
km.zones.push_back(zone);
|
||||
}
|
||||
return km; // empty zones in -> empty Keymap (silence)
|
||||
}
|
||||
|
||||
// --- Performance-map instance state (setState/getState) -----------------------
|
||||
|
||||
namespace {
|
||||
|
||||
void putU32le(std::vector<std::uint8_t>& out, std::uint32_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||
}
|
||||
|
||||
// A bounded little-endian reader over a byte blob. Every read is length-checked; once a
|
||||
// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated
|
||||
// blob degrades to a partial/empty parse rather than reading out of bounds.
|
||||
struct ByteReader {
|
||||
const std::vector<std::uint8_t>& bytes;
|
||||
std::size_t pos = 0;
|
||||
bool ok = true;
|
||||
|
||||
explicit ByteReader(const std::vector<std::uint8_t>& b) : bytes(b) {}
|
||||
|
||||
std::uint32_t u32() {
|
||||
if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; }
|
||||
const std::uint32_t v = static_cast<std::uint32_t>(bytes[pos]) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
|
||||
pos += 4;
|
||||
return v;
|
||||
}
|
||||
std::uint8_t u8() {
|
||||
if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; }
|
||||
return bytes[pos++];
|
||||
}
|
||||
std::string str(std::uint32_t len) {
|
||||
if (!ok || pos + len > bytes.size()) { ok = false; return {}; }
|
||||
std::string s(reinterpret_cast<const char*>(bytes.data() + pos), len);
|
||||
pos += len;
|
||||
return s;
|
||||
}
|
||||
// Signed ints go on the wire as u32 two's-complement (fixed 32-bit width).
|
||||
int i32() { return static_cast<int>(static_cast<std::int32_t>(u32())); }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putU32le(out, kPerformanceStateVersion);
|
||||
putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
|
||||
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
|
||||
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
|
||||
out.push_back(z.rootOverride ? 1 : 0);
|
||||
if (z.rootOverride) {
|
||||
putU32le(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes) {
|
||||
PerformanceMap map;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return map; // no version tag -> empty
|
||||
|
||||
// BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes,
|
||||
// no length prefix). Lift it to one full-keyboard zone playing that id.
|
||||
if (version == kSelectionStateVersion) {
|
||||
const std::string id = deserializeSelection(bytes);
|
||||
if (!id.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = id;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
||||
|
||||
const std::uint32_t count = r.u32();
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
PerformanceZone z;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
z.sampleId = r.str(idLen);
|
||||
z.lowNote = r.i32();
|
||||
z.highNote = r.i32();
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) z.rootOverride = r.i32();
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId) {
|
||||
std::vector<std::uint8_t> out;
|
||||
out.resize(4 + sampleId.size());
|
||||
|
||||
@@ -85,6 +85,107 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> monoFrames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop);
|
||||
|
||||
// --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
|
||||
//
|
||||
// The performance map is the keymap the user authors IN the instrument: several bank
|
||||
// samples zoned across the keyboard, each with a key range and a root note. It is a
|
||||
// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never
|
||||
// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but
|
||||
// OVERRIDABLE here — the override lives on the zone, never on `Sample`.
|
||||
//
|
||||
// Pure value type: it names bank samples by id (the stable seam key) and holds no PCM.
|
||||
// The shell resolves each id's WAV over the file seam and decodes it; the pure zone-build
|
||||
// stitches the decoded frames + this map into a sampler_core Keymap.
|
||||
|
||||
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range,
|
||||
// with an optional root-note override. rootOverride absent -> repitch from the bank
|
||||
// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty).
|
||||
struct PerformanceZone {
|
||||
std::string sampleId; // bank sample id this zone plays
|
||||
int lowNote = 0; // inclusive
|
||||
int highNote = 127; // inclusive
|
||||
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
|
||||
};
|
||||
|
||||
// The instrument's performance map: an ordered list of zones. Order is authoritative for
|
||||
// overlap resolution (OVERLAP POLICY: first zone in order wins, mirroring the S3 core's
|
||||
// first-match Keymap::resolve — overlaps are neither rejected nor clamped, the earlier
|
||||
// zone simply takes the contested keys; documented, deterministic).
|
||||
struct PerformanceMap {
|
||||
std::vector<PerformanceZone> zones;
|
||||
|
||||
bool empty() const { return zones.empty(); }
|
||||
};
|
||||
|
||||
// One resolved zone ready for the shell to decode + the pure build to stitch: the bank
|
||||
// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats
|
||||
// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct
|
||||
// from PerformanceZone (which names an id) — this is the id resolved against the live bank.
|
||||
struct ResolvedZone {
|
||||
std::string relativePath; // project-relative; the shell resolves + decodes it
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
SampleLoop loop; // bank intrinsic
|
||||
};
|
||||
|
||||
// The result of resolving a performance map against the live bank blob. `zones` are the
|
||||
// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is
|
||||
// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a
|
||||
// zone naming a deleted/moved-out sample is DROPPED cleanly — not an error, not silence
|
||||
// for the whole map — and its id is reported here so the editor can flag/prune it).
|
||||
struct ResolvedPerformance {
|
||||
std::vector<ResolvedZone> zones;
|
||||
std::vector<std::string> droppedSampleIds;
|
||||
};
|
||||
|
||||
// Resolve a performance map against the live "banks" ext-state blob. Pure: shared
|
||||
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank
|
||||
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
|
||||
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
|
||||
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result (the shell
|
||||
// then falls back to Tier-0 — see reloadFromBank).
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map);
|
||||
|
||||
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the
|
||||
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One
|
||||
// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is
|
||||
// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order
|
||||
// is preserved so first-match overlap resolution matches the map's authored order. A zone
|
||||
// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the
|
||||
// map). Empty zones in -> empty Keymap (silence).
|
||||
struct DecodedZonePcm {
|
||||
std::vector<AudioSample> monoFrames;
|
||||
int sampleRate = 44100;
|
||||
};
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded);
|
||||
|
||||
// --- Performance-map instance state (VST3 setState/getState) -----------------
|
||||
//
|
||||
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
|
||||
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
|
||||
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
|
||||
// truncation/wrong-version by design (bounded reads, never throws across the host).
|
||||
//
|
||||
// Format (v2): 4-byte LE version tag (== 2), then a 4-byte LE zone count, then per zone:
|
||||
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
|
||||
// 1 byte hasOverride (0/1), 4-byte LE rootOverride (present only when hasOverride==1).
|
||||
// BACK-COMPAT: a v1 blob (the S4 single-selection format: version tag 1 + id bytes) is
|
||||
// lifted to a single full-keyboard zone playing that id (no override) — so an instance
|
||||
// saved under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob
|
||||
// deserializes to an EMPTY map (the instrument falls back to Tier-0 first-sample).
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The performance map serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
|
||||
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||
//
|
||||
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
|
||||
|
||||
@@ -198,6 +198,118 @@ static void testSampleRowHitTestMatchesDrawnRows() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- keymap editor (S5 Tier-1 UI) --------------------------------------------
|
||||
|
||||
static void testKeymapLayoutSplitsCanvas() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
// The left sample list and right zone panel partition the canvas with no overlap and
|
||||
// no gap: the list's right edge is the panel's left edge.
|
||||
CHECK(L.sampleList.left == L.base.canvas.left);
|
||||
CHECK(L.sampleList.right == L.zonePanel.left);
|
||||
CHECK(L.zonePanel.right == L.base.canvas.right);
|
||||
CHECK(L.sampleList.top == L.base.canvas.top);
|
||||
CHECK(L.zonePanel.top == L.base.canvas.top);
|
||||
CHECK(L.sampleList.bottom == L.base.canvas.bottom);
|
||||
CHECK(L.zonePanel.bottom == L.base.canvas.bottom);
|
||||
CHECK(L.sampleList.width() > 0 && L.zonePanel.width() > 0);
|
||||
// Add-Zone button caps the panel; zone rows stack below it.
|
||||
CHECK(L.addZoneButton.top == L.zonePanel.top);
|
||||
CHECK(L.addZoneButton.left == L.zonePanel.left && L.addZoneButton.right == L.zonePanel.right);
|
||||
CHECK(L.zoneRowArea.top == L.addZoneButton.bottom);
|
||||
CHECK(L.zoneRowArea.bottom == L.zonePanel.bottom);
|
||||
}
|
||||
|
||||
static void checkNoInversion(const KeymapEditorLayout& L) {
|
||||
CHECK(L.sampleList.right >= L.sampleList.left);
|
||||
CHECK(L.zonePanel.right >= L.zonePanel.left);
|
||||
CHECK(L.addZoneButton.right >= L.addZoneButton.left);
|
||||
CHECK(L.addZoneButton.bottom >= L.addZoneButton.top);
|
||||
CHECK(L.zoneRowArea.right >= L.zoneRowArea.left);
|
||||
CHECK(L.zoneRowArea.bottom >= L.zoneRowArea.top);
|
||||
// Regions stay within the client area.
|
||||
CHECK(L.zonePanel.right <= L.base.canvas.right);
|
||||
}
|
||||
|
||||
static void testKeymapLayoutTinyAndZeroNoInversion() {
|
||||
checkNoInversion(layoutKeymapEditor(30, 30));
|
||||
checkNoInversion(layoutKeymapEditor(0, 0));
|
||||
// A click anywhere on a zero layout hits no zone and no Add button.
|
||||
const KeymapEditorLayout Z = layoutKeymapEditor(0, 0);
|
||||
CHECK(zoneHitTest(Z, 3, 0, 0).zoneIndex == -1);
|
||||
CHECK(!addZoneHitTest(Z, 0, 0));
|
||||
}
|
||||
|
||||
static void testKeymapSampleRowInLeftColumn() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect r0 = keymapSampleRowRect(L, 0);
|
||||
// Rows live in the LEFT column (not the full canvas width).
|
||||
CHECK(r0.left == L.sampleList.left && r0.right == L.sampleList.right);
|
||||
CHECK(r0.right < L.base.canvas.right); // strictly left of the zone panel
|
||||
CHECK(r0.top == L.sampleList.top && r0.height() == kSampleRowHeight);
|
||||
// Hit-test maps a left-column click to the row and rejects a click in the zone panel.
|
||||
const int midY = (r0.top + r0.bottom) / 2;
|
||||
CHECK(keymapSampleRowHitTest(L, 3, r0.left + 2, midY) == 0);
|
||||
CHECK(keymapSampleRowHitTest(L, 3, L.zonePanel.left + 2, midY) == -1);
|
||||
}
|
||||
|
||||
static void testAddZoneHitTest() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const int cx = (L.addZoneButton.left + L.addZoneButton.right) / 2;
|
||||
const int cy = (L.addZoneButton.top + L.addZoneButton.bottom) / 2;
|
||||
CHECK(addZoneHitTest(L, cx, cy));
|
||||
// A click in the zone-row area below the button is NOT the Add button.
|
||||
CHECK(!addZoneHitTest(L, cx, L.zoneRowArea.top + 2));
|
||||
// A click in the left list is NOT the Add button.
|
||||
CHECK(!addZoneHitTest(L, L.sampleList.left + 2, L.sampleList.top + 2));
|
||||
}
|
||||
|
||||
static void testZoneRowStacksAndSelects() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect z0 = zoneRowRect(L, 0);
|
||||
const Rect z1 = zoneRowRect(L, 1);
|
||||
CHECK(z0.top == L.zoneRowArea.top && z0.height() == kZoneRowHeight);
|
||||
CHECK(z1.top == z0.bottom); // stacked, no gap
|
||||
CHECK(z0.left == L.zoneRowArea.left && z0.right == L.zoneRowArea.right);
|
||||
// A click on the LABEL area (left part of a zone row) selects the zone with no field.
|
||||
const int labelX = z0.left + 2; // far left = label, not a control
|
||||
const int midY = (z0.top + z0.bottom) / 2;
|
||||
const ZoneHit h = zoneHitTest(L, 2, labelX, midY);
|
||||
CHECK(h.zoneIndex == 0 && h.field == ZoneField::kZoneNone);
|
||||
}
|
||||
|
||||
static void testZoneRowControlsMapToFields() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect row = zoneRowRect(L, 0);
|
||||
const int midY = (row.top + row.bottom) / 2;
|
||||
// The seven controls occupy the rightmost 7*kZoneCtrlWidth px, left-to-right:
|
||||
// low-, low+, high-, high+, root-, root+, delete.
|
||||
const int block = row.right - 7 * kZoneCtrlWidth;
|
||||
const ZoneField expected[7] = {
|
||||
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
|
||||
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
|
||||
ZoneField::kDelete,
|
||||
};
|
||||
for (int s = 0; s < 7; ++s) {
|
||||
const int x = block + s * kZoneCtrlWidth + kZoneCtrlWidth / 2; // center of slot s
|
||||
const ZoneHit h = zoneHitTest(L, 1, x, midY);
|
||||
CHECK(h.zoneIndex == 0);
|
||||
CHECK(h.zoneIndex == 0 && h.field == expected[s]);
|
||||
}
|
||||
}
|
||||
|
||||
static void testZoneHitTestMisses() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect row = zoneRowRect(L, 0);
|
||||
const int midY = (row.top + row.bottom) / 2;
|
||||
// Zero zones -> always miss.
|
||||
CHECK(zoneHitTest(L, 0, row.left + 2, midY).zoneIndex == -1);
|
||||
// Below the last zone row -> miss.
|
||||
const Rect last = zoneRowRect(L, 2);
|
||||
CHECK(zoneHitTest(L, 3, row.left + 2, last.bottom + 1).zoneIndex == -1);
|
||||
// Left of the zone panel (in the sample list) -> miss.
|
||||
CHECK(zoneHitTest(L, 3, L.sampleList.left + 2, midY).zoneIndex == -1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testContainsHalfOpen();
|
||||
testContainsDegenerate();
|
||||
@@ -212,6 +324,13 @@ int main() {
|
||||
testSampleRowHitTestMapsClickToRow();
|
||||
testSampleRowHitTestMisses();
|
||||
testSampleRowHitTestMatchesDrawnRows();
|
||||
testKeymapLayoutSplitsCanvas();
|
||||
testKeymapLayoutTinyAndZeroNoInversion();
|
||||
testKeymapSampleRowInLeftColumn();
|
||||
testAddZoneHitTest();
|
||||
testZoneRowStacksAndSelects();
|
||||
testZoneRowControlsMapToFields();
|
||||
testZoneHitTestMisses();
|
||||
|
||||
if (g_fail == 0) std::printf("editor_geometry: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
|
||||
@@ -350,6 +350,214 @@ static void testWavTrimToDownmixPipelineMono() {
|
||||
CHECK(approx(mono[0], 0.0) && approx(mono[1], 0.5) && approx(mono[2], 1.0));
|
||||
}
|
||||
|
||||
// --- performance map: resolvePerformance --------------------------------------
|
||||
|
||||
static PerformanceZone zone(const std::string& id, int lo, int hi,
|
||||
std::optional<int> rootOverride = std::nullopt) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = id;
|
||||
z.lowNote = lo;
|
||||
z.highNote = hi;
|
||||
z.rootOverride = rootOverride;
|
||||
return z;
|
||||
}
|
||||
|
||||
static void testResolveEmptyMap() {
|
||||
// An empty performance map resolves to nothing (the shell falls back to Tier 0).
|
||||
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
|
||||
const ResolvedPerformance r = resolvePerformance(json, PerformanceMap{});
|
||||
CHECK(r.zones.empty());
|
||||
CHECK(r.droppedSampleIds.empty());
|
||||
}
|
||||
|
||||
static void testResolveEmptyBlob() {
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("a", 0, 127));
|
||||
CHECK(resolvePerformance("", m).zones.empty()); // no bank
|
||||
CHECK(resolvePerformance("{garbage", m).zones.empty()); // malformed
|
||||
}
|
||||
|
||||
static void testResolveMultiZoneAcrossBanks() {
|
||||
const std::string json = bookJson(
|
||||
{makeSample("a", "Kick", "b/a.wav", 36)},
|
||||
{makeSample("b", "Snare", "b/b.wav", 38)});
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("a", 36, 47));
|
||||
m.zones.push_back(zone("b", 48, 59));
|
||||
const ResolvedPerformance r = resolvePerformance(json, m);
|
||||
CHECK(r.zones.size() == 2);
|
||||
CHECK(r.droppedSampleIds.empty());
|
||||
// Order preserved; paths + ranges threaded.
|
||||
CHECK(r.zones.size() == 2 && r.zones[0].relativePath == "b/a.wav");
|
||||
CHECK(r.zones.size() == 2 && r.zones[0].lowNote == 36 && r.zones[0].highNote == 47);
|
||||
CHECK(r.zones.size() == 2 && r.zones[1].relativePath == "b/b.wav");
|
||||
CHECK(r.zones.size() == 2 && r.zones[1].lowNote == 48 && r.zones[1].highNote == 59);
|
||||
}
|
||||
|
||||
static void testResolveStaleIdDropsZone() {
|
||||
// STALE-ID POLICY: a zone naming a deleted sample is dropped, its id reported; the
|
||||
// surviving zone still resolves (the whole map is NOT abandoned).
|
||||
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("a", 0, 59));
|
||||
m.zones.push_back(zone("ghost", 60, 127)); // no such sample
|
||||
const ResolvedPerformance r = resolvePerformance(json, m);
|
||||
CHECK(r.zones.size() == 1);
|
||||
CHECK(r.zones.size() == 1 && r.zones[0].relativePath == "b/a.wav");
|
||||
CHECK(r.droppedSampleIds.size() == 1);
|
||||
CHECK(r.droppedSampleIds.size() == 1 && r.droppedSampleIds[0] == "ghost");
|
||||
}
|
||||
|
||||
static void testResolveRootPrecedence() {
|
||||
// Override beats bank intrinsic beats middle-C default.
|
||||
const std::string json = bookJson(
|
||||
{makeSample("rooted", "R", "b/r.wav", 40), // bank intrinsic 40
|
||||
makeSample("unrooted", "U", "b/u.wav", std::nullopt)}, // no intrinsic
|
||||
{});
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("rooted", 0, 42)); // no override -> 40
|
||||
m.zones.push_back(zone("rooted", 43, 84, /*override=*/72)); // override -> 72
|
||||
m.zones.push_back(zone("unrooted", 85, 127)); // no intrinsic -> 60
|
||||
const ResolvedPerformance r = resolvePerformance(json, m);
|
||||
CHECK(r.zones.size() == 3);
|
||||
CHECK(r.zones.size() == 3 && r.zones[0].rootNote == 40); // bank intrinsic
|
||||
CHECK(r.zones.size() == 3 && r.zones[1].rootNote == 72); // override wins
|
||||
CHECK(r.zones.size() == 3 && r.zones[2].rootNote == 60); // middle-C default
|
||||
}
|
||||
|
||||
static void testResolveLoopThreaded() {
|
||||
Sample s = makeSample("a", "Pad", "b/a.wav", 60);
|
||||
s.loop = LoopPoints{200, 800};
|
||||
const std::string json = bookJson({s}, {});
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("a", 0, 127));
|
||||
const ResolvedPerformance r = resolvePerformance(json, m);
|
||||
CHECK(r.zones.size() == 1);
|
||||
CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop);
|
||||
CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 200 && r.zones[0].loop.end == 800);
|
||||
}
|
||||
|
||||
// --- performance map: buildZonedKeymap ----------------------------------------
|
||||
|
||||
static void testBuildZonedKeymapMultiZone() {
|
||||
std::vector<ResolvedZone> zones;
|
||||
ResolvedZone z0; z0.lowNote = 36; z0.highNote = 47; z0.rootNote = 36; zones.push_back(z0);
|
||||
ResolvedZone z1; z1.lowNote = 48; z1.highNote = 59; z1.rootNote = 48; zones.push_back(z1);
|
||||
std::vector<DecodedZonePcm> decoded;
|
||||
decoded.push_back(DecodedZonePcm{{0.1f, 0.2f}, 44100});
|
||||
decoded.push_back(DecodedZonePcm{{0.3f, 0.4f, 0.5f}, 48000});
|
||||
const Keymap km = buildZonedKeymap(zones, decoded);
|
||||
CHECK(km.samples.size() == 2);
|
||||
CHECK(km.zones.size() == 2);
|
||||
// Zone 0 -> sample 0, rooted 36, range 36..47; zone 1 -> sample 1, rooted 48.
|
||||
CHECK(km.zones.size() == 2 && km.zones[0].sampleIndex == 0 && km.zones[0].rootNote == 36);
|
||||
CHECK(km.zones.size() == 2 && km.zones[0].lowNote == 36 && km.zones[0].highNote == 47);
|
||||
CHECK(km.zones.size() == 2 && km.zones[1].sampleIndex == 1 && km.zones[1].rootNote == 48);
|
||||
CHECK(km.samples.size() == 2 && km.samples[1].sampleRate == 48000);
|
||||
CHECK(km.samples.size() == 2 && km.samples[1].frames.size() == 3);
|
||||
// Resolution: a note in each range lands in the right zone.
|
||||
CHECK(km.resolve(40, 100).matched && km.resolve(40, 100).zoneIndex == 0);
|
||||
CHECK(km.resolve(52, 100).matched && km.resolve(52, 100).zoneIndex == 1);
|
||||
// A note outside every zone does not match (no-play, not zone 0).
|
||||
CHECK(!km.resolve(24, 100).matched);
|
||||
}
|
||||
|
||||
static void testBuildZonedKeymapDropsEmptyPcm() {
|
||||
// A zone whose decoded WAV is empty is dropped; the other zone survives, and the
|
||||
// survivor's sampleIndex points at ITS sample (not the dropped one's slot).
|
||||
std::vector<ResolvedZone> zones;
|
||||
ResolvedZone z0; z0.lowNote = 0; z0.highNote = 63; z0.rootNote = 60; zones.push_back(z0);
|
||||
ResolvedZone z1; z1.lowNote = 64; z1.highNote = 127; z1.rootNote = 72; zones.push_back(z1);
|
||||
std::vector<DecodedZonePcm> decoded;
|
||||
decoded.push_back(DecodedZonePcm{{}, 44100}); // empty -> dropped
|
||||
decoded.push_back(DecodedZonePcm{{0.9f}, 44100}); // survives
|
||||
const Keymap km = buildZonedKeymap(zones, decoded);
|
||||
CHECK(km.samples.size() == 1);
|
||||
CHECK(km.zones.size() == 1);
|
||||
CHECK(km.zones.size() == 1 && km.zones[0].sampleIndex == 0); // remapped to slot 0
|
||||
CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 64 && km.zones[0].rootNote == 72);
|
||||
}
|
||||
|
||||
static void testBuildZonedKeymapOverlapFirstWins() {
|
||||
// OVERLAP POLICY: two zones share keys; the FIRST in order wins the contested note
|
||||
// (mirrors the S3 core's first-match resolve).
|
||||
std::vector<ResolvedZone> zones;
|
||||
ResolvedZone z0; z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60; zones.push_back(z0);
|
||||
ResolvedZone z1; z1.lowNote = 60; z1.highNote = 72; z1.rootNote = 48; zones.push_back(z1);
|
||||
std::vector<DecodedZonePcm> decoded;
|
||||
decoded.push_back(DecodedZonePcm{{0.1f}, 44100});
|
||||
decoded.push_back(DecodedZonePcm{{0.2f}, 44100});
|
||||
const Keymap km = buildZonedKeymap(zones, decoded);
|
||||
CHECK(km.zones.size() == 2);
|
||||
// Note 64 is in both zones; first-match resolves to zone 0.
|
||||
CHECK(km.resolve(64, 100).matched && km.resolve(64, 100).zoneIndex == 0);
|
||||
}
|
||||
|
||||
static void testBuildZonedKeymapEmpty() {
|
||||
// No zones -> empty keymap (silence).
|
||||
const Keymap km = buildZonedKeymap({}, {});
|
||||
CHECK(km.samples.empty() && km.zones.empty());
|
||||
CHECK(!km.resolve(60, 100).matched);
|
||||
}
|
||||
|
||||
// --- performance-map state: serialize / deserialize ---------------------------
|
||||
|
||||
static void testPerformanceStateRoundTrip() {
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("kick", 36, 47)); // no override
|
||||
m.zones.push_back(zone("snare", 48, 59, /*override=*/50)); // with override
|
||||
const std::vector<std::uint8_t> bytes = serializePerformance(m);
|
||||
const PerformanceMap back = deserializePerformance(bytes);
|
||||
CHECK(back.zones.size() == 2);
|
||||
CHECK(back.zones.size() == 2 && back.zones[0].sampleId == "kick");
|
||||
CHECK(back.zones.size() == 2 && back.zones[0].lowNote == 36 && back.zones[0].highNote == 47);
|
||||
CHECK(back.zones.size() == 2 && !back.zones[0].rootOverride.has_value());
|
||||
CHECK(back.zones.size() == 2 && back.zones[1].sampleId == "snare");
|
||||
CHECK(back.zones.size() == 2 && back.zones[1].rootOverride.has_value() &&
|
||||
*back.zones[1].rootOverride == 50);
|
||||
}
|
||||
|
||||
static void testPerformanceStateEmpty() {
|
||||
const std::vector<std::uint8_t> bytes = serializePerformance(PerformanceMap{});
|
||||
// Just the version + zero-count header.
|
||||
CHECK(bytes.size() == 8);
|
||||
CHECK(deserializePerformance(bytes).zones.empty());
|
||||
}
|
||||
|
||||
static void testPerformanceStateV1BackCompat() {
|
||||
// A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone.
|
||||
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-sample-id");
|
||||
const PerformanceMap back = deserializePerformance(v1);
|
||||
CHECK(back.zones.size() == 1);
|
||||
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id");
|
||||
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127);
|
||||
CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value());
|
||||
// A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection").
|
||||
CHECK(deserializePerformance(serializeSelection("")).zones.empty());
|
||||
}
|
||||
|
||||
static void testPerformanceStateGarbage() {
|
||||
// Unknown version / truncated / empty -> empty map (never throws).
|
||||
CHECK(deserializePerformance({}).zones.empty());
|
||||
CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}).zones.empty()); // unknown version
|
||||
// Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty.
|
||||
std::vector<std::uint8_t> t;
|
||||
t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0); // version 2
|
||||
t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0); // count 1
|
||||
// (no zone payload)
|
||||
CHECK(deserializePerformance(t).zones.empty());
|
||||
}
|
||||
|
||||
static void testPerformanceStateNegativeNotesRoundTrip() {
|
||||
// Notes are clamped in the UI, but the wire format must survive the full int range so
|
||||
// a hand-set/legacy value round-trips without corruption (two's-complement on the wire).
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("s", 0, 127, /*override=*/0));
|
||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
||||
CHECK(back.zones.size() == 1 && back.zones[0].rootOverride.has_value() &&
|
||||
*back.zones[0].rootOverride == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testSelectByIdHit();
|
||||
testSelectFirstSampleFallbackOnEmptyId();
|
||||
@@ -374,6 +582,21 @@ int main() {
|
||||
testSelectionStateTruncated();
|
||||
testWavTrimToDownmixPipelineStereo();
|
||||
testWavTrimToDownmixPipelineMono();
|
||||
testResolveEmptyMap();
|
||||
testResolveEmptyBlob();
|
||||
testResolveMultiZoneAcrossBanks();
|
||||
testResolveStaleIdDropsZone();
|
||||
testResolveRootPrecedence();
|
||||
testResolveLoopThreaded();
|
||||
testBuildZonedKeymapMultiZone();
|
||||
testBuildZonedKeymapDropsEmptyPcm();
|
||||
testBuildZonedKeymapOverlapFirstWins();
|
||||
testBuildZonedKeymapEmpty();
|
||||
testPerformanceStateRoundTrip();
|
||||
testPerformanceStateEmpty();
|
||||
testPerformanceStateV1BackCompat();
|
||||
testPerformanceStateGarbage();
|
||||
testPerformanceStateNegativeNotesRoundTrip();
|
||||
|
||||
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
|
||||
Reference in New Issue
Block a user