S11: waveform view with draggable start/loop markers + zero-crossing snap

Pure waveform_view module (frame<->pixel markers, zero-crossing snap); per-zone
loop/start overrides on PerformanceZone with self-versioning zone payload; core
start-point read offset; editor waveform surface with drag machine.
This commit is contained in:
2026-07-26 21:26:23 -04:00
parent ee82b50fb7
commit 9743547dff
12 changed files with 1052 additions and 36 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");
@@ -711,16 +831,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();
+76
View File
@@ -520,6 +520,78 @@ static void testAbsentLoopGoesSilent() {
for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6));
}
// ---------------------------------------------------------------------------
// start point (S11): the voice's initial read position is SampleData::startFrame.
// ---------------------------------------------------------------------------
static void testStartFrameOffsetsInitialRead() {
// A per-frame ramp (frame i holds i*0.01) so the first rendered value pinpoints the
// read position. startFrame = 30 -> the first output frame reads frame 30 (0.30).
SampleData s;
s.frames.resize(100);
for (int i = 0; i < 100; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
s.rootNote = 60;
s.startFrame = 30;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
std::vector<AudioSample> out;
eng.render(out, 3);
CHECK(approx(out[0], 0.30, 1e-4)); // starts at frame 30, not 0
CHECK(approx(out[1], 0.31, 1e-4)); // advances by unity ratio
CHECK(approx(out[2], 0.32, 1e-4));
}
static void testStartFrameZeroIsUnchanged() {
// startFrame default 0 is exactly the pre-S11 behavior: read begins at frame 0.
SampleData s;
s.frames.resize(20);
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
s.rootNote = 60; // startFrame stays 0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], 0.0, 1e-6)); // frame 0
}
static void testStartFrameOutOfRangeClampsToZero() {
// A start point at/past the sample end degrades to frame 0 (play from the top), never an
// out-of-bounds read that would start the voice already exhausted.
SampleData s = dcSample(10, 60); // 10 frames of 1.0
s.startFrame = 10; // == frameCount: out of range
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 5);
// Reads from frame 0: the DC sample plays its 1.0 body rather than an immediate idle.
CHECK(eng.activeVoiceCount() == 1);
CHECK(approx(out[0], 1.0, 1e-4));
}
static void testStartFrameWithLoop() {
// Start point and loop compose: begin reading mid-sample, then sustain the loop region.
SampleData s;
s.frames.resize(40);
for (int i = 0; i < 40; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body
s.rootNote = 60;
s.startFrame = 10; // begin at frame 10
s.loop.hasLoop = true;
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 200);
CHECK(approx(out[0], 0.10, 1e-4)); // started at frame 10
CHECK(eng.activeVoiceCount() == 1); // loop sustains it
for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.5, 1e-4));
}
// ---------------------------------------------------------------------------
// velocity -> volume.
// ---------------------------------------------------------------------------
@@ -580,6 +652,10 @@ int main() {
testZeroLengthLoopGoesSilent();
testSingleFrameLoop();
testAbsentLoopGoesSilent();
testStartFrameOffsetsInitialRead();
testStartFrameZeroIsUnchanged();
testStartFrameOutOfRangeClampsToZero();
testStartFrameWithLoop();
testVelocityToVolume();
testPolyphonyMixesAdditively();
+223
View File
@@ -0,0 +1,223 @@
// Standalone tests for reasampler::vst::waveform_view — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure tests. Assert the S11 waveform surface's
// frame<->pixel mapping, marker grab regions, drag-delta frame resolver (with clamps), and
// the zero-crossing snap — the geometry + snap that back the draggable start/loop markers.
//
// Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width);
// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection);
// resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width
// no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower,
// no-crossing keeps target, target clamp, degenerate buffers).
#include "../src/vst/waveform_view.h"
#include <cstdio>
#include <vector>
using namespace reasampler::vst;
using reasampler::AudioSample;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// A comfortable waveform area: 1000px wide, offset so left != 0 (catches origin bugs).
static Rect wideArea() { return Rect{20, 10, 1020, 90}; } // width 1000
// --- frameToX / xToFrame ------------------------------------------------------
static void testFrameToXEndpoints() {
const Rect a = wideArea();
CHECK(frameToX(a, 1000, 0) == a.left); // frame 0 -> left edge
CHECK(frameToX(a, 1000, 1000) == a.right); // frameCount -> right edge
CHECK(frameToX(a, 1000, 500) == a.left + 500); // midpoint (1:1 here)
}
static void testFrameToXClampsOutOfRange() {
const Rect a = wideArea();
CHECK(frameToX(a, 1000, -50) == a.left); // below 0 pins left
CHECK(frameToX(a, 1000, 5000) == a.right); // above count pins right
}
static void testFrameToXDegenerate() {
const Rect a = wideArea();
CHECK(frameToX(a, 0, 100) == a.left); // no frames -> left
const Rect z = Rect{5, 5, 5, 45}; // zero width
CHECK(frameToX(z, 1000, 500) == z.left);
}
static void testXToFrameInverse() {
const Rect a = wideArea();
CHECK(xToFrame(a, 1000, a.left) == 0);
CHECK(xToFrame(a, 1000, a.right) == 1000);
CHECK(xToFrame(a, 1000, a.left + 250) == 250); // 1:1 map here
}
static void testXToFrameClampsOutside() {
const Rect a = wideArea();
CHECK(xToFrame(a, 1000, a.left - 100) == 0); // left of area -> 0
CHECK(xToFrame(a, 1000, a.right + 100) == 1000); // right of area -> frameCount
CHECK(xToFrame(a, 0, a.left + 10) == 0); // no frames -> 0
}
static void testFrameToXRoundTrip() {
// Round-trip at a non-1:1 scale: 800px area over 2000 frames (2.5 frames/px). frameToX then
// xToFrame should land within a couple frames (rounding both directions).
const Rect a = Rect{0, 0, 800, 60};
for (std::int64_t f = 0; f <= 2000; f += 137) {
const int x = frameToX(a, 2000, f);
const std::int64_t back = xToFrame(a, 2000, x);
CHECK(back >= f - 3 && back <= f + 3);
}
}
// --- markerAtPoint ------------------------------------------------------------
static void testMarkerAtPointGrabsWithinBand() {
const Rect a = wideArea();
// Markers at frames 100, 500, 900 -> x = left+100, left+500, left+900.
const std::int64_t frames[3] = {100, 500, 900};
const int midY = a.top + a.height() / 2;
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 100, midY) == 0);
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, midY) == 1);
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 900, midY) == 2);
// Within the grab band on either side of the line.
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 + kMarkerGrabWidth, midY) == 1);
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 - kMarkerGrabWidth, midY) == 1);
}
static void testMarkerAtPointMissesBetween() {
const Rect a = wideArea();
const std::int64_t frames[3] = {100, 500, 900};
const int midY = a.top + a.height() / 2;
// Well away from any marker line.
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 300, midY) == -1);
// Off the area vertically.
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, a.top - 5) == -1);
}
static void testMarkerAtPointFirstMatchOnOverlap() {
const Rect a = wideArea();
// Two markers at the same frame -> first in order wins.
const std::int64_t frames[2] = {400, 400};
const int midY = a.top + a.height() / 2;
CHECK(markerAtPoint(a, 1000, frames, 2, a.left + 400, midY) == 0);
}
static void testMarkerAtPointRejectsNullEmpty() {
const Rect a = wideArea();
const int midY = a.top + a.height() / 2;
CHECK(markerAtPoint(a, 1000, nullptr, 3, a.left + 100, midY) == -1);
const std::int64_t frames[1] = {100};
CHECK(markerAtPoint(a, 1000, frames, 0, a.left + 100, midY) == -1);
}
// --- resolveDragFrame ---------------------------------------------------------
static void testResolveDragFrameShift() {
const Rect a = wideArea(); // 1:1 (1000px / 1000 frames)
CHECK(resolveDragFrame(a, 1000, 300, 0) == 300); // zero delta -> unchanged
CHECK(resolveDragFrame(a, 1000, 300, 100) == 400); // +100px -> +100 frames
CHECK(resolveDragFrame(a, 1000, 300, -50) == 250); // -50px -> -50 frames
}
static void testResolveDragFrameClamps() {
const Rect a = wideArea();
CHECK(resolveDragFrame(a, 1000, 50, -500) == 0); // clamp low
CHECK(resolveDragFrame(a, 1000, 950, 500) == 1000); // clamp high (== frameCount)
}
static void testResolveDragFrameRounds() {
// 500px area over 1000 frames -> 2 frames/px. A +3px drag -> round(6.0)=6; the rounding is
// at the frame centre. Use a scale where a fractional result appears.
const Rect a = Rect{0, 0, 300, 60}; // 1000 frames / 300px = 3.33 frames/px
// +3px -> 3*1000/300 = 10.0 -> 10 frames.
CHECK(resolveDragFrame(a, 1000, 100, 3) == 110);
// +1px -> 1000/300 = 3.33 -> rounds to 3.
CHECK(resolveDragFrame(a, 1000, 100, 1) == 103);
}
static void testResolveDragFrameDegenerate() {
const Rect z = Rect{0, 0, 0, 60}; // zero width
CHECK(resolveDragFrame(z, 1000, 300, 100) == 300); // pinned to start
const Rect a = wideArea();
CHECK(resolveDragFrame(a, 0, 300, 100) == 0); // no frames -> clamp(start)=0
// startFrame out of range is clamped first.
CHECK(resolveDragFrame(a, 1000, 5000, 0) == 1000);
}
// --- nearestZeroCrossing ------------------------------------------------------
static void testZeroCrossingNearest() {
// Crossings (sign change from i-1 to i): i=4 (1->-1), i=5 (-1->1), i=10 (1->-1).
std::vector<AudioSample> pcm = {1, 1, 1, 1, -1, 1, 1, 1, 1, 1, -1, -1};
// Target 4 is itself a crossing -> 4.
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 4);
// Nearest to 6: crossing 5 (dist 1) beats 4 (dist 2) and 10 (dist 4) -> 5.
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 6) == 5);
// Nearest to 9: crossing 10 (dist 1) beats 5 (dist 4) -> 10.
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 9) == 10);
}
static void testZeroCrossingSampleOnZero() {
// A sample exactly 0 is its own crossing (frame index of the zero sample).
std::vector<AudioSample> pcm = {1, 1, 0, 1, 1};
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2);
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 3) == 2);
}
static void testZeroCrossingEquidistantTieToLower() {
// Crossings at i=2 (1->-1) and i=6 (-1->1). Target 4 is equidistant (dist 2) -> lower (2).
std::vector<AudioSample> pcm = {1, 1, -1, -1, -1, -1, 1, 1};
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 2);
}
static void testZeroCrossingNoneKeepsTarget() {
// All one sign -> no crossing -> the (clamped) target comes back unchanged.
std::vector<AudioSample> pcm = {0.5f, 0.6f, 0.7f, 0.8f};
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2);
}
static void testZeroCrossingClampsTarget() {
std::vector<AudioSample> pcm = {1, -1, 1, -1}; // crossings at 1,2,3
// Target beyond the end clamps to frames-1 (3) then finds crossing at 3.
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 999) == 3);
// Negative target clamps to 0; nearest crossing is 1.
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), -999) == 1);
}
static void testZeroCrossingDegenerate() {
CHECK(nearestZeroCrossing(nullptr, 0, 5) == 0);
std::vector<AudioSample> one = {1};
CHECK(nearestZeroCrossing(one.data(), 1, 0) == 0); // <2 frames -> clamped target
}
int main() {
testFrameToXEndpoints();
testFrameToXClampsOutOfRange();
testFrameToXDegenerate();
testXToFrameInverse();
testXToFrameClampsOutside();
testFrameToXRoundTrip();
testMarkerAtPointGrabsWithinBand();
testMarkerAtPointMissesBetween();
testMarkerAtPointFirstMatchOnOverlap();
testMarkerAtPointRejectsNullEmpty();
testResolveDragFrameShift();
testResolveDragFrameClamps();
testResolveDragFrameRounds();
testResolveDragFrameDegenerate();
testZeroCrossingNearest();
testZeroCrossingSampleOnZero();
testZeroCrossingEquidistantTieToLower();
testZeroCrossingNoneKeepsTarget();
testZeroCrossingClampsTarget();
testZeroCrossingDegenerate();
if (g_fail == 0) std::printf("waveform_view: all tests passed\n");
else std::printf("waveform_view: %d FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
}