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:
@@ -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 six fixed-width nudge/delete mini-buttons on the right (low-, low+, high-, high+,
|
||||
// root-, root+) followed by a delete button. 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 six nudges + delete occupy fixed-width
|
||||
// slots on the right edge (right-to-left: delete, root+, root-, high+, high-, low+, low-);
|
||||
// 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
|
||||
|
||||
+164
-22
@@ -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,
|
||||
|
||||
@@ -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 the v1 lift produced a single zone we also
|
||||
// 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,36 +214,58 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
||||
std::unique_ptr<LoadedInstrument> built;
|
||||
|
||||
if (banksJson) {
|
||||
// 2. Pick the sample (shared bank_book JSON parse — NOT a second parser).
|
||||
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);
|
||||
// Record which id actually resolved so a first-sample fallback
|
||||
// (empty stored id) becomes the concrete selection.
|
||||
resolvedId = selectedSampleId();
|
||||
}
|
||||
// 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the
|
||||
|
||||
@@ -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;
|
||||
|
||||
+165
-1
@@ -3,7 +3,9 @@
|
||||
|
||||
#include "sample_map.h"
|
||||
|
||||
#include <cstring> // std::memcpy
|
||||
#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
|
||||
|
||||
Reference in New Issue
Block a user