Merge ps-w4-tier1-keymap: S5 Tier 1 — zoned keymap editor + performance-map persistence

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