Merge ps-w8-t2-waveform: S11 waveform view + draggable loop points

This commit is contained in:
2026-07-26 22:06:42 -04:00
12 changed files with 1092 additions and 38 deletions
+128 -2
View File
@@ -479,6 +479,40 @@ static void testResolveLoopThreaded() {
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);
// No loop override + no startPoint -> effective start is 0 (S11 default).
CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 0);
}
static void testResolveLoopOverrideWins() {
// S11: the instrument's per-zone loopOverride beats the bank's S2 loop intrinsic, and the
// startPoint feeds the effective startFrame — without mutating the bank.
Sample s = makeSample("a", "Pad", "b/a.wav", 60);
s.loop = LoopPoints{200, 800}; // bank intrinsic
const std::string json = bookJson({s}, {});
PerformanceMap m;
PerformanceZone z = zone("a", 0, 127);
SampleLoop over; over.hasLoop = true; over.start = 1000; over.end = 4000;
z.loopOverride = over; // instrument override
z.startPoint = 512; // start offset
m.zones.push_back(z);
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop);
CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 1000 && r.zones[0].loop.end == 4000);
CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 512);
}
static void testResolveLoopOverrideDisablesLoop() {
// A loopOverride with hasLoop=false explicitly REMOVES the bank's loop for this instance
// (override present-but-empty wins over the intrinsic — a deliberate "no loop here").
Sample s = makeSample("a", "Pad", "b/a.wav", 60);
s.loop = LoopPoints{200, 800};
const std::string json = bookJson({s}, {});
PerformanceMap m;
PerformanceZone z = zone("a", 0, 127);
z.loopOverride = SampleLoop{}; // hasLoop=false, start=end=0
m.zones.push_back(z);
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 1 && !r.zones[0].loop.hasLoop);
}
// --- performance map: buildZonedKeymap ----------------------------------------
@@ -506,6 +540,24 @@ static void testBuildZonedKeymapMultiZone() {
CHECK(!km.resolve(24, 100).matched);
}
static void testBuildZonedKeymapThreadsLoopAndStart() {
// S11: the effective loop + start on a ResolvedZone reach the core's SampleData so the
// voice honors them at note-on.
std::vector<ResolvedZone> zones;
ResolvedZone z0;
z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60;
z0.loop.hasLoop = true; z0.loop.start = 3; z0.loop.end = 7;
z0.startFrame = 2;
zones.push_back(z0);
std::vector<DecodedZonePcm> decoded;
decoded.push_back(DecodedZonePcm{{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, 44100});
const Keymap km = buildZonedKeymap(zones, decoded);
CHECK(km.samples.size() == 1);
CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop &&
km.samples[0].loop.start == 3 && km.samples[0].loop.end == 7);
CHECK(km.samples.size() == 1 && km.samples[0].startFrame == 2);
}
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).
@@ -563,11 +615,79 @@ static void testPerformanceStateRoundTrip() {
static void testPerformanceStateEmpty() {
const std::vector<std::uint8_t> bytes = serializePerformance(PerformanceMap{});
// Just the version + zero-count header.
CHECK(bytes.size() == 8);
// Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4).
CHECK(bytes.size() == 16);
CHECK(deserializePerformance(bytes).zones.empty());
}
static void testPerformanceStateLoopStartRoundTrip() {
// S11: the per-zone loopOverride + startPoint survive the payload-v2 round trip.
PerformanceMap m;
PerformanceZone z = zone("pad", 24, 96, /*override=*/64);
SampleLoop lp; lp.hasLoop = true; lp.start = 12345; lp.end = 67890;
z.loopOverride = lp;
z.startPoint = 4096;
m.zones.push_back(z);
// A second zone with NO overrides proves the optional tail is per-record.
m.zones.push_back(zone("kick", 0, 23));
const PerformanceMap back = deserializePerformance(serializePerformance(m));
CHECK(back.zones.size() == 2);
CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() &&
*back.zones[0].rootOverride == 64);
CHECK(back.zones.size() == 2 && back.zones[0].loopOverride.has_value() &&
back.zones[0].loopOverride->hasLoop &&
back.zones[0].loopOverride->start == 12345 &&
back.zones[0].loopOverride->end == 67890);
CHECK(back.zones.size() == 2 && back.zones[0].startPoint.has_value() &&
*back.zones[0].startPoint == 4096);
// Zone 1: no overrides -> all optionals absent after round trip.
CHECK(back.zones.size() == 2 && !back.zones[1].loopOverride.has_value());
CHECK(back.zones.size() == 2 && !back.zones[1].startPoint.has_value());
}
static void testPerformanceStateV1PayloadBackCompat() {
// A pre-S11 PAYLOAD v1 blob (no format marker: envelope v2 + bare count + short records)
// parses cleanly with the loop/start overrides defaulting absent. Hand-build the exact
// shipped shape to prove the reader still accepts the marker-less payload.
std::vector<std::uint8_t> b;
auto u32 = [&](std::uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
};
u32(2); // envelope version 2
u32(1); // zone count 1 (NOT the marker -> payload v1)
const std::string id = "legacy";
u32(static_cast<std::uint32_t>(id.size()));
b.insert(b.end(), id.begin(), id.end());
u32(10); // lowNote
u32(40); // highNote
b.push_back(0); // hasRootOverride = 0 (record ends here in v1)
const PerformanceMap back = deserializePerformance(b);
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy");
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40);
CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value());
CHECK(back.zones.size() == 1 && !back.zones[0].startPoint.has_value());
}
static void testComponentStateLoopStartRoundTrip() {
// The overrides also round-trip through the v3 ComponentState envelope (zones nest inside
// it), so the processor's live getState/setState preserves them — the composition property.
ComponentState s;
s.selectionId = "pick";
PerformanceZone z = zone("pick", 0, 127);
SampleLoop lp; lp.hasLoop = true; lp.start = 500; lp.end = 9000;
z.loopOverride = lp;
z.startPoint = 128;
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s));
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() &&
back.map.zones[0].loopOverride->start == 500 &&
back.map.zones[0].loopOverride->end == 9000);
CHECK(back.map.zones.size() == 1 && back.map.zones[0].startPoint.has_value() &&
*back.map.zones[0].startPoint == 128);
}
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");
@@ -854,16 +974,22 @@ int main() {
testResolveStaleIdDropsZone();
testResolveRootPrecedence();
testResolveLoopThreaded();
testResolveLoopOverrideWins();
testResolveLoopOverrideDisablesLoop();
testBuildZonedKeymapMultiZone();
testBuildZonedKeymapThreadsLoopAndStart();
testBuildZonedKeymapDropsEmptyPcm();
testBuildZonedKeymapOverlapFirstWins();
testBuildZonedKeymapEmpty();
testPerformanceStateRoundTrip();
testPerformanceStateEmpty();
testPerformanceStateLoopStartRoundTrip();
testPerformanceStateV1PayloadBackCompat();
testPerformanceStateV1BackCompat();
testPerformanceStateGarbage();
testPerformanceStateNegativeNotesRoundTrip();
testComponentStateRoundTrip();
testComponentStateLoopStartRoundTrip();
testComponentStateSelectionOnlyNoZones();
testComponentStateEmptyIsEmpty();
testComponentStateV1BackCompat();