S5 Tier-1: zoned keymap editor + performance-map playback/persistence
Zone editor in the IPlugView LICE surface, zoned resolution built off-thread into the LoadedInstrument keymap with Tier-0 fallback, performance map in VST3 component state with v1 back-compat. Pure resolve/build/serialize + geometry with CTest coverage.
This commit is contained in:
@@ -198,6 +198,118 @@ static void testSampleRowHitTestMatchesDrawnRows() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- keymap editor (S5 Tier-1 UI) --------------------------------------------
|
||||
|
||||
static void testKeymapLayoutSplitsCanvas() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
// The left sample list and right zone panel partition the canvas with no overlap and
|
||||
// no gap: the list's right edge is the panel's left edge.
|
||||
CHECK(L.sampleList.left == L.base.canvas.left);
|
||||
CHECK(L.sampleList.right == L.zonePanel.left);
|
||||
CHECK(L.zonePanel.right == L.base.canvas.right);
|
||||
CHECK(L.sampleList.top == L.base.canvas.top);
|
||||
CHECK(L.zonePanel.top == L.base.canvas.top);
|
||||
CHECK(L.sampleList.bottom == L.base.canvas.bottom);
|
||||
CHECK(L.zonePanel.bottom == L.base.canvas.bottom);
|
||||
CHECK(L.sampleList.width() > 0 && L.zonePanel.width() > 0);
|
||||
// Add-Zone button caps the panel; zone rows stack below it.
|
||||
CHECK(L.addZoneButton.top == L.zonePanel.top);
|
||||
CHECK(L.addZoneButton.left == L.zonePanel.left && L.addZoneButton.right == L.zonePanel.right);
|
||||
CHECK(L.zoneRowArea.top == L.addZoneButton.bottom);
|
||||
CHECK(L.zoneRowArea.bottom == L.zonePanel.bottom);
|
||||
}
|
||||
|
||||
static void checkNoInversion(const KeymapEditorLayout& L) {
|
||||
CHECK(L.sampleList.right >= L.sampleList.left);
|
||||
CHECK(L.zonePanel.right >= L.zonePanel.left);
|
||||
CHECK(L.addZoneButton.right >= L.addZoneButton.left);
|
||||
CHECK(L.addZoneButton.bottom >= L.addZoneButton.top);
|
||||
CHECK(L.zoneRowArea.right >= L.zoneRowArea.left);
|
||||
CHECK(L.zoneRowArea.bottom >= L.zoneRowArea.top);
|
||||
// Regions stay within the client area.
|
||||
CHECK(L.zonePanel.right <= L.base.canvas.right);
|
||||
}
|
||||
|
||||
static void testKeymapLayoutTinyAndZeroNoInversion() {
|
||||
checkNoInversion(layoutKeymapEditor(30, 30));
|
||||
checkNoInversion(layoutKeymapEditor(0, 0));
|
||||
// A click anywhere on a zero layout hits no zone and no Add button.
|
||||
const KeymapEditorLayout Z = layoutKeymapEditor(0, 0);
|
||||
CHECK(zoneHitTest(Z, 3, 0, 0).zoneIndex == -1);
|
||||
CHECK(!addZoneHitTest(Z, 0, 0));
|
||||
}
|
||||
|
||||
static void testKeymapSampleRowInLeftColumn() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect r0 = keymapSampleRowRect(L, 0);
|
||||
// Rows live in the LEFT column (not the full canvas width).
|
||||
CHECK(r0.left == L.sampleList.left && r0.right == L.sampleList.right);
|
||||
CHECK(r0.right < L.base.canvas.right); // strictly left of the zone panel
|
||||
CHECK(r0.top == L.sampleList.top && r0.height() == kSampleRowHeight);
|
||||
// Hit-test maps a left-column click to the row and rejects a click in the zone panel.
|
||||
const int midY = (r0.top + r0.bottom) / 2;
|
||||
CHECK(keymapSampleRowHitTest(L, 3, r0.left + 2, midY) == 0);
|
||||
CHECK(keymapSampleRowHitTest(L, 3, L.zonePanel.left + 2, midY) == -1);
|
||||
}
|
||||
|
||||
static void testAddZoneHitTest() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const int cx = (L.addZoneButton.left + L.addZoneButton.right) / 2;
|
||||
const int cy = (L.addZoneButton.top + L.addZoneButton.bottom) / 2;
|
||||
CHECK(addZoneHitTest(L, cx, cy));
|
||||
// A click in the zone-row area below the button is NOT the Add button.
|
||||
CHECK(!addZoneHitTest(L, cx, L.zoneRowArea.top + 2));
|
||||
// A click in the left list is NOT the Add button.
|
||||
CHECK(!addZoneHitTest(L, L.sampleList.left + 2, L.sampleList.top + 2));
|
||||
}
|
||||
|
||||
static void testZoneRowStacksAndSelects() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect z0 = zoneRowRect(L, 0);
|
||||
const Rect z1 = zoneRowRect(L, 1);
|
||||
CHECK(z0.top == L.zoneRowArea.top && z0.height() == kZoneRowHeight);
|
||||
CHECK(z1.top == z0.bottom); // stacked, no gap
|
||||
CHECK(z0.left == L.zoneRowArea.left && z0.right == L.zoneRowArea.right);
|
||||
// A click on the LABEL area (left part of a zone row) selects the zone with no field.
|
||||
const int labelX = z0.left + 2; // far left = label, not a control
|
||||
const int midY = (z0.top + z0.bottom) / 2;
|
||||
const ZoneHit h = zoneHitTest(L, 2, labelX, midY);
|
||||
CHECK(h.zoneIndex == 0 && h.field == ZoneField::kZoneNone);
|
||||
}
|
||||
|
||||
static void testZoneRowControlsMapToFields() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect row = zoneRowRect(L, 0);
|
||||
const int midY = (row.top + row.bottom) / 2;
|
||||
// The seven controls occupy the rightmost 7*kZoneCtrlWidth px, left-to-right:
|
||||
// low-, low+, high-, high+, root-, root+, delete.
|
||||
const int block = row.right - 7 * kZoneCtrlWidth;
|
||||
const ZoneField expected[7] = {
|
||||
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
|
||||
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
|
||||
ZoneField::kDelete,
|
||||
};
|
||||
for (int s = 0; s < 7; ++s) {
|
||||
const int x = block + s * kZoneCtrlWidth + kZoneCtrlWidth / 2; // center of slot s
|
||||
const ZoneHit h = zoneHitTest(L, 1, x, midY);
|
||||
CHECK(h.zoneIndex == 0);
|
||||
CHECK(h.zoneIndex == 0 && h.field == expected[s]);
|
||||
}
|
||||
}
|
||||
|
||||
static void testZoneHitTestMisses() {
|
||||
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
|
||||
const Rect row = zoneRowRect(L, 0);
|
||||
const int midY = (row.top + row.bottom) / 2;
|
||||
// Zero zones -> always miss.
|
||||
CHECK(zoneHitTest(L, 0, row.left + 2, midY).zoneIndex == -1);
|
||||
// Below the last zone row -> miss.
|
||||
const Rect last = zoneRowRect(L, 2);
|
||||
CHECK(zoneHitTest(L, 3, row.left + 2, last.bottom + 1).zoneIndex == -1);
|
||||
// Left of the zone panel (in the sample list) -> miss.
|
||||
CHECK(zoneHitTest(L, 3, L.sampleList.left + 2, midY).zoneIndex == -1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testContainsHalfOpen();
|
||||
testContainsDegenerate();
|
||||
@@ -212,6 +324,13 @@ int main() {
|
||||
testSampleRowHitTestMapsClickToRow();
|
||||
testSampleRowHitTestMisses();
|
||||
testSampleRowHitTestMatchesDrawnRows();
|
||||
testKeymapLayoutSplitsCanvas();
|
||||
testKeymapLayoutTinyAndZeroNoInversion();
|
||||
testKeymapSampleRowInLeftColumn();
|
||||
testAddZoneHitTest();
|
||||
testZoneRowStacksAndSelects();
|
||||
testZoneRowControlsMapToFields();
|
||||
testZoneHitTestMisses();
|
||||
|
||||
if (g_fail == 0) std::printf("editor_geometry: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
|
||||
@@ -350,6 +350,214 @@ static void testWavTrimToDownmixPipelineMono() {
|
||||
CHECK(approx(mono[0], 0.0) && approx(mono[1], 0.5) && approx(mono[2], 1.0));
|
||||
}
|
||||
|
||||
// --- performance map: resolvePerformance --------------------------------------
|
||||
|
||||
static PerformanceZone zone(const std::string& id, int lo, int hi,
|
||||
std::optional<int> rootOverride = std::nullopt) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = id;
|
||||
z.lowNote = lo;
|
||||
z.highNote = hi;
|
||||
z.rootOverride = rootOverride;
|
||||
return z;
|
||||
}
|
||||
|
||||
static void testResolveEmptyMap() {
|
||||
// An empty performance map resolves to nothing (the shell falls back to Tier 0).
|
||||
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
|
||||
const ResolvedPerformance r = resolvePerformance(json, PerformanceMap{});
|
||||
CHECK(r.zones.empty());
|
||||
CHECK(r.droppedSampleIds.empty());
|
||||
}
|
||||
|
||||
static void testResolveEmptyBlob() {
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("a", 0, 127));
|
||||
CHECK(resolvePerformance("", m).zones.empty()); // no bank
|
||||
CHECK(resolvePerformance("{garbage", m).zones.empty()); // malformed
|
||||
}
|
||||
|
||||
static void testResolveMultiZoneAcrossBanks() {
|
||||
const std::string json = bookJson(
|
||||
{makeSample("a", "Kick", "b/a.wav", 36)},
|
||||
{makeSample("b", "Snare", "b/b.wav", 38)});
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("a", 36, 47));
|
||||
m.zones.push_back(zone("b", 48, 59));
|
||||
const ResolvedPerformance r = resolvePerformance(json, m);
|
||||
CHECK(r.zones.size() == 2);
|
||||
CHECK(r.droppedSampleIds.empty());
|
||||
// Order preserved; paths + ranges threaded.
|
||||
CHECK(r.zones.size() == 2 && r.zones[0].relativePath == "b/a.wav");
|
||||
CHECK(r.zones.size() == 2 && r.zones[0].lowNote == 36 && r.zones[0].highNote == 47);
|
||||
CHECK(r.zones.size() == 2 && r.zones[1].relativePath == "b/b.wav");
|
||||
CHECK(r.zones.size() == 2 && r.zones[1].lowNote == 48 && r.zones[1].highNote == 59);
|
||||
}
|
||||
|
||||
static void testResolveStaleIdDropsZone() {
|
||||
// STALE-ID POLICY: a zone naming a deleted sample is dropped, its id reported; the
|
||||
// surviving zone still resolves (the whole map is NOT abandoned).
|
||||
const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {});
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("a", 0, 59));
|
||||
m.zones.push_back(zone("ghost", 60, 127)); // no such sample
|
||||
const ResolvedPerformance r = resolvePerformance(json, m);
|
||||
CHECK(r.zones.size() == 1);
|
||||
CHECK(r.zones.size() == 1 && r.zones[0].relativePath == "b/a.wav");
|
||||
CHECK(r.droppedSampleIds.size() == 1);
|
||||
CHECK(r.droppedSampleIds.size() == 1 && r.droppedSampleIds[0] == "ghost");
|
||||
}
|
||||
|
||||
static void testResolveRootPrecedence() {
|
||||
// Override beats bank intrinsic beats middle-C default.
|
||||
const std::string json = bookJson(
|
||||
{makeSample("rooted", "R", "b/r.wav", 40), // bank intrinsic 40
|
||||
makeSample("unrooted", "U", "b/u.wav", std::nullopt)}, // no intrinsic
|
||||
{});
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("rooted", 0, 42)); // no override -> 40
|
||||
m.zones.push_back(zone("rooted", 43, 84, /*override=*/72)); // override -> 72
|
||||
m.zones.push_back(zone("unrooted", 85, 127)); // no intrinsic -> 60
|
||||
const ResolvedPerformance r = resolvePerformance(json, m);
|
||||
CHECK(r.zones.size() == 3);
|
||||
CHECK(r.zones.size() == 3 && r.zones[0].rootNote == 40); // bank intrinsic
|
||||
CHECK(r.zones.size() == 3 && r.zones[1].rootNote == 72); // override wins
|
||||
CHECK(r.zones.size() == 3 && r.zones[2].rootNote == 60); // middle-C default
|
||||
}
|
||||
|
||||
static void testResolveLoopThreaded() {
|
||||
Sample s = makeSample("a", "Pad", "b/a.wav", 60);
|
||||
s.loop = LoopPoints{200, 800};
|
||||
const std::string json = bookJson({s}, {});
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("a", 0, 127));
|
||||
const ResolvedPerformance r = resolvePerformance(json, m);
|
||||
CHECK(r.zones.size() == 1);
|
||||
CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop);
|
||||
CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 200 && r.zones[0].loop.end == 800);
|
||||
}
|
||||
|
||||
// --- performance map: buildZonedKeymap ----------------------------------------
|
||||
|
||||
static void testBuildZonedKeymapMultiZone() {
|
||||
std::vector<ResolvedZone> zones;
|
||||
ResolvedZone z0; z0.lowNote = 36; z0.highNote = 47; z0.rootNote = 36; zones.push_back(z0);
|
||||
ResolvedZone z1; z1.lowNote = 48; z1.highNote = 59; z1.rootNote = 48; zones.push_back(z1);
|
||||
std::vector<DecodedZonePcm> decoded;
|
||||
decoded.push_back(DecodedZonePcm{{0.1f, 0.2f}, 44100});
|
||||
decoded.push_back(DecodedZonePcm{{0.3f, 0.4f, 0.5f}, 48000});
|
||||
const Keymap km = buildZonedKeymap(zones, decoded);
|
||||
CHECK(km.samples.size() == 2);
|
||||
CHECK(km.zones.size() == 2);
|
||||
// Zone 0 -> sample 0, rooted 36, range 36..47; zone 1 -> sample 1, rooted 48.
|
||||
CHECK(km.zones.size() == 2 && km.zones[0].sampleIndex == 0 && km.zones[0].rootNote == 36);
|
||||
CHECK(km.zones.size() == 2 && km.zones[0].lowNote == 36 && km.zones[0].highNote == 47);
|
||||
CHECK(km.zones.size() == 2 && km.zones[1].sampleIndex == 1 && km.zones[1].rootNote == 48);
|
||||
CHECK(km.samples.size() == 2 && km.samples[1].sampleRate == 48000);
|
||||
CHECK(km.samples.size() == 2 && km.samples[1].frames.size() == 3);
|
||||
// Resolution: a note in each range lands in the right zone.
|
||||
CHECK(km.resolve(40, 100).matched && km.resolve(40, 100).zoneIndex == 0);
|
||||
CHECK(km.resolve(52, 100).matched && km.resolve(52, 100).zoneIndex == 1);
|
||||
// A note outside every zone does not match (no-play, not zone 0).
|
||||
CHECK(!km.resolve(24, 100).matched);
|
||||
}
|
||||
|
||||
static void testBuildZonedKeymapDropsEmptyPcm() {
|
||||
// A zone whose decoded WAV is empty is dropped; the other zone survives, and the
|
||||
// survivor's sampleIndex points at ITS sample (not the dropped one's slot).
|
||||
std::vector<ResolvedZone> zones;
|
||||
ResolvedZone z0; z0.lowNote = 0; z0.highNote = 63; z0.rootNote = 60; zones.push_back(z0);
|
||||
ResolvedZone z1; z1.lowNote = 64; z1.highNote = 127; z1.rootNote = 72; zones.push_back(z1);
|
||||
std::vector<DecodedZonePcm> decoded;
|
||||
decoded.push_back(DecodedZonePcm{{}, 44100}); // empty -> dropped
|
||||
decoded.push_back(DecodedZonePcm{{0.9f}, 44100}); // survives
|
||||
const Keymap km = buildZonedKeymap(zones, decoded);
|
||||
CHECK(km.samples.size() == 1);
|
||||
CHECK(km.zones.size() == 1);
|
||||
CHECK(km.zones.size() == 1 && km.zones[0].sampleIndex == 0); // remapped to slot 0
|
||||
CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 64 && km.zones[0].rootNote == 72);
|
||||
}
|
||||
|
||||
static void testBuildZonedKeymapOverlapFirstWins() {
|
||||
// OVERLAP POLICY: two zones share keys; the FIRST in order wins the contested note
|
||||
// (mirrors the S3 core's first-match resolve).
|
||||
std::vector<ResolvedZone> zones;
|
||||
ResolvedZone z0; z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60; zones.push_back(z0);
|
||||
ResolvedZone z1; z1.lowNote = 60; z1.highNote = 72; z1.rootNote = 48; zones.push_back(z1);
|
||||
std::vector<DecodedZonePcm> decoded;
|
||||
decoded.push_back(DecodedZonePcm{{0.1f}, 44100});
|
||||
decoded.push_back(DecodedZonePcm{{0.2f}, 44100});
|
||||
const Keymap km = buildZonedKeymap(zones, decoded);
|
||||
CHECK(km.zones.size() == 2);
|
||||
// Note 64 is in both zones; first-match resolves to zone 0.
|
||||
CHECK(km.resolve(64, 100).matched && km.resolve(64, 100).zoneIndex == 0);
|
||||
}
|
||||
|
||||
static void testBuildZonedKeymapEmpty() {
|
||||
// No zones -> empty keymap (silence).
|
||||
const Keymap km = buildZonedKeymap({}, {});
|
||||
CHECK(km.samples.empty() && km.zones.empty());
|
||||
CHECK(!km.resolve(60, 100).matched);
|
||||
}
|
||||
|
||||
// --- performance-map state: serialize / deserialize ---------------------------
|
||||
|
||||
static void testPerformanceStateRoundTrip() {
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("kick", 36, 47)); // no override
|
||||
m.zones.push_back(zone("snare", 48, 59, /*override=*/50)); // with override
|
||||
const std::vector<std::uint8_t> bytes = serializePerformance(m);
|
||||
const PerformanceMap back = deserializePerformance(bytes);
|
||||
CHECK(back.zones.size() == 2);
|
||||
CHECK(back.zones.size() == 2 && back.zones[0].sampleId == "kick");
|
||||
CHECK(back.zones.size() == 2 && back.zones[0].lowNote == 36 && back.zones[0].highNote == 47);
|
||||
CHECK(back.zones.size() == 2 && !back.zones[0].rootOverride.has_value());
|
||||
CHECK(back.zones.size() == 2 && back.zones[1].sampleId == "snare");
|
||||
CHECK(back.zones.size() == 2 && back.zones[1].rootOverride.has_value() &&
|
||||
*back.zones[1].rootOverride == 50);
|
||||
}
|
||||
|
||||
static void testPerformanceStateEmpty() {
|
||||
const std::vector<std::uint8_t> bytes = serializePerformance(PerformanceMap{});
|
||||
// Just the version + zero-count header.
|
||||
CHECK(bytes.size() == 8);
|
||||
CHECK(deserializePerformance(bytes).zones.empty());
|
||||
}
|
||||
|
||||
static void testPerformanceStateV1BackCompat() {
|
||||
// A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone.
|
||||
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-sample-id");
|
||||
const PerformanceMap back = deserializePerformance(v1);
|
||||
CHECK(back.zones.size() == 1);
|
||||
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id");
|
||||
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127);
|
||||
CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value());
|
||||
// A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection").
|
||||
CHECK(deserializePerformance(serializeSelection("")).zones.empty());
|
||||
}
|
||||
|
||||
static void testPerformanceStateGarbage() {
|
||||
// Unknown version / truncated / empty -> empty map (never throws).
|
||||
CHECK(deserializePerformance({}).zones.empty());
|
||||
CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}).zones.empty()); // unknown version
|
||||
// Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty.
|
||||
std::vector<std::uint8_t> t;
|
||||
t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0); // version 2
|
||||
t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0); // count 1
|
||||
// (no zone payload)
|
||||
CHECK(deserializePerformance(t).zones.empty());
|
||||
}
|
||||
|
||||
static void testPerformanceStateNegativeNotesRoundTrip() {
|
||||
// Notes are clamped in the UI, but the wire format must survive the full int range so
|
||||
// a hand-set/legacy value round-trips without corruption (two's-complement on the wire).
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("s", 0, 127, /*override=*/0));
|
||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
||||
CHECK(back.zones.size() == 1 && back.zones[0].rootOverride.has_value() &&
|
||||
*back.zones[0].rootOverride == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testSelectByIdHit();
|
||||
testSelectFirstSampleFallbackOnEmptyId();
|
||||
@@ -374,6 +582,21 @@ int main() {
|
||||
testSelectionStateTruncated();
|
||||
testWavTrimToDownmixPipelineStereo();
|
||||
testWavTrimToDownmixPipelineMono();
|
||||
testResolveEmptyMap();
|
||||
testResolveEmptyBlob();
|
||||
testResolveMultiZoneAcrossBanks();
|
||||
testResolveStaleIdDropsZone();
|
||||
testResolveRootPrecedence();
|
||||
testResolveLoopThreaded();
|
||||
testBuildZonedKeymapMultiZone();
|
||||
testBuildZonedKeymapDropsEmptyPcm();
|
||||
testBuildZonedKeymapOverlapFirstWins();
|
||||
testBuildZonedKeymapEmpty();
|
||||
testPerformanceStateRoundTrip();
|
||||
testPerformanceStateEmpty();
|
||||
testPerformanceStateV1BackCompat();
|
||||
testPerformanceStateGarbage();
|
||||
testPerformanceStateNegativeNotesRoundTrip();
|
||||
|
||||
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
|
||||
Reference in New Issue
Block a user