diff --git a/src/vst/editor_geometry.cpp b/src/vst/editor_geometry.cpp index 83a9b7d..f838420 100644 --- a/src/vst/editor_geometry.cpp +++ b/src/vst/editor_geometry.cpp @@ -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 diff --git a/src/vst/editor_geometry.h b/src/vst/editor_geometry.h index 2fb3697..0b57487 100644 --- a/src/vst/editor_geometry.h +++ b/src/vst/editor_geometry.h @@ -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 diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 3daf183..b21b4fb 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -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& 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{}; selectedId_ = processor_->selectedSampleId(); + map_ = processor_->performanceMap(); + if (selectedZone_ >= static_cast(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(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(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(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(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(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(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, diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index c89d7d8..3012a75 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -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 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 diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index ed3c337..682ec63 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #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 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 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 bytes = readFileBytes(abs); + const WavLayout layout = parseWavLayout(bytes); + if (!layout.valid) return std::nullopt; + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + std::vector mono = downmixToMono(interleaved, layout.channelCount); + if (mono.empty()) return std::nullopt; + DecodedZonePcm out; + out.monoFrames = std::move(mono); + out.sampleRate = static_cast(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 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 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 bytes = serializePerformance(performanceMap()); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), static_cast(bytes.size()), nullptr); @@ -150,6 +184,16 @@ void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { selectedSampleId_ = id; } +PerformanceMap ReaSamplerProcessor::performanceMap() { + std::lock_guard lock(performanceMutex_); + return performanceMap_; +} + +void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { + std::lock_guard 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 built; if (banksJson) { - // 2. Pick the sample (shared bank_book JSON parse — NOT a second parser). - std::optional 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 bytes = readFileBytes(abs); - const WavLayout layout = parseWavLayout(bytes); - if (layout.valid) { - const std::size_t frames = layout.frameCount(); - std::vector interleaved = - extractFloatFrames(bytes, layout, 0, frames); - std::vector mono = - downmixToMono(interleaved, layout.channelCount); - if (!mono.empty()) { - Keymap km = buildTier0Keymap( - std::move(mono), - static_cast(layout.sampleRate), sel->rootNote, - sel->loop); - built = std::make_unique( - 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 decoded; + std::vector kept; + decoded.reserve(resolved.zones.size()); + kept.reserve(resolved.zones.size()); + for (const ResolvedZone& rz : resolved.zones) { + std::optional 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 sel = + selectSample(*banksJson, selectedSampleId()); + if (sel) { + std::optional 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( + std::move(km), kMaxVoices, tier0Adsr(sampleRate_), gen); + } } // 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index c0bffbc..dd4efce 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -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 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; diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index a87a13f..6056268 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -3,7 +3,9 @@ #include "sample_map.h" -#include // std::memcpy +#include // std::min +#include // std::memcpy +#include // std::move namespace reasampler { @@ -103,6 +105,168 @@ Keymap buildTier0Keymap(std::vector 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 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& zones, + const std::vector& 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& out, std::uint32_t v) { + out.push_back(static_cast(v & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((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& bytes; + std::size_t pos = 0; + bool ok = true; + + explicit ByteReader(const std::vector& b) : bytes(b) {} + + std::uint32_t u32() { + if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; } + const std::uint32_t v = static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(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(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(static_cast(u32())); } +}; + +} // namespace + +std::vector serializePerformance(const PerformanceMap& map) { + std::vector out; + putU32le(out, kPerformanceStateVersion); + putU32le(out, static_cast(map.zones.size())); + for (const PerformanceZone& z : map.zones) { + putU32le(out, static_cast(z.sampleId.size())); + out.insert(out.end(), z.sampleId.begin(), z.sampleId.end()); + putU32le(out, static_cast(static_cast(z.lowNote))); + putU32le(out, static_cast(static_cast(z.highNote))); + out.push_back(z.rootOverride ? 1 : 0); + if (z.rootOverride) { + putU32le(out, + static_cast(static_cast(*z.rootOverride))); + } + } + return out; +} + +PerformanceMap deserializePerformance(const std::vector& 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 serializeSelection(const std::string& sampleId) { std::vector out; out.resize(4 + sampleId.size()); diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 6d3177a..6cc794c 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -85,6 +85,107 @@ std::vector downmixToMono(const std::vector& interleav Keymap buildTier0Keymap(std::vector 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 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 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 zones; + std::vector 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 monoFrames; + int sampleRate = 44100; +}; +Keymap buildZonedKeymap(const std::vector& zones, + const std::vector& 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 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& bytes); + // --- Instance state (VST3 setState/getState) -------------------------------- // // The instrument's OWN state is which bank sample it plays (D-B: the selection is a diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp index d808584..2d363bf 100644 --- a/tests/test_editor_geometry.cpp +++ b/tests/test_editor_geometry.cpp @@ -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; diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 70e2bc5..f35945e 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -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 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 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 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 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 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 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 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 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 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 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 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;