314 lines
14 KiB
C++
314 lines
14 KiB
C++
// 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);
|
|
// columnMinMax (per-pixel-column bin merge: 1:1, upsample, downsample, degenerate).
|
|
|
|
#include "../src/vst/waveform_view.h"
|
|
|
|
#include <cstdio>
|
|
#include <vector>
|
|
|
|
using namespace reasampler::vst;
|
|
using reasampler::AudioSample;
|
|
using reasampler::MinMax;
|
|
using reasampler::ChannelEnvelope;
|
|
|
|
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
|
|
}
|
|
|
|
// --- columnMinMax -------------------------------------------------------------
|
|
// Helpers: build a ChannelEnvelope from parallel min/max arrays.
|
|
static ChannelEnvelope makeEnvelope(const std::vector<float>& mins,
|
|
const std::vector<float>& maxs) {
|
|
ChannelEnvelope env(mins.size());
|
|
for (std::size_t i = 0; i < mins.size(); ++i) {
|
|
env[i] = MinMax{mins[i], maxs[i]};
|
|
}
|
|
return env;
|
|
}
|
|
|
|
static void testColumnMinMaxOneToOne() {
|
|
// 4 bins, 4 pixel columns: each column maps exactly one bin.
|
|
ChannelEnvelope env = makeEnvelope({-1.0f, -0.5f, 0.0f, 0.5f},
|
|
{ 0.5f, 0.0f, 0.5f, 1.0f});
|
|
// col 0 → bin 0, col 1 → bin 1, etc.
|
|
CHECK(columnMinMax(env, 4, 0).min == -1.0f && columnMinMax(env, 4, 0).max == 0.5f);
|
|
CHECK(columnMinMax(env, 4, 1).min == -0.5f && columnMinMax(env, 4, 1).max == 0.0f);
|
|
CHECK(columnMinMax(env, 4, 2).min == 0.0f && columnMinMax(env, 4, 2).max == 0.5f);
|
|
CHECK(columnMinMax(env, 4, 3).min == 0.5f && columnMinMax(env, 4, 3).max == 1.0f);
|
|
}
|
|
|
|
static void testColumnMinMaxUpsample() {
|
|
// 2 bins, 4 pixel columns: columns 0,1 map to bin 0; columns 2,3 map to bin 1.
|
|
// Verifies that upsampling (more columns than bins) returns the enclosing bin
|
|
// and does not leave any column empty.
|
|
ChannelEnvelope env = makeEnvelope({-1.0f, 0.5f}, {0.0f, 1.0f});
|
|
// col 0: (0*2)/4=0, (1*2)/4=0 -> empty range -> fallback bin 0.
|
|
CHECK(columnMinMax(env, 4, 0).min == -1.0f && columnMinMax(env, 4, 0).max == 0.0f);
|
|
CHECK(columnMinMax(env, 4, 1).min == -1.0f && columnMinMax(env, 4, 1).max == 0.0f);
|
|
CHECK(columnMinMax(env, 4, 2).min == 0.5f && columnMinMax(env, 4, 2).max == 1.0f);
|
|
CHECK(columnMinMax(env, 4, 3).min == 0.5f && columnMinMax(env, 4, 3).max == 1.0f);
|
|
}
|
|
|
|
static void testColumnMinMaxDownsample() {
|
|
// 4 bins, 2 pixel columns: each column merges 2 bins.
|
|
// col 0: bins [0,2) → min(-1,-0.5)=-1, max(0.5,0.0)=0.5.
|
|
// col 1: bins [2,4) → min(0.0,0.5)=0.0, max(0.5,1.0)=1.0.
|
|
ChannelEnvelope env = makeEnvelope({-1.0f, -0.5f, 0.0f, 0.5f},
|
|
{ 0.5f, 0.0f, 0.5f, 1.0f});
|
|
CHECK(columnMinMax(env, 2, 0).min == -1.0f && columnMinMax(env, 2, 0).max == 0.5f);
|
|
CHECK(columnMinMax(env, 2, 1).min == 0.0f && columnMinMax(env, 2, 1).max == 1.0f);
|
|
}
|
|
|
|
static void testColumnMinMaxColClamp() {
|
|
// col out of [0, innerW-1] is clamped: negative clamps to 0, >= innerW clamps to last.
|
|
ChannelEnvelope env = makeEnvelope({-0.5f, 0.5f}, {-0.1f, 0.9f});
|
|
CHECK(columnMinMax(env, 2, -5).min == -0.5f); // clamps to col 0
|
|
CHECK(columnMinMax(env, 2, 999).max == 0.9f); // clamps to col 1
|
|
}
|
|
|
|
static void testColumnMinMaxDegenerate() {
|
|
ChannelEnvelope empty;
|
|
// Empty envelope → {0, 0}.
|
|
MinMax z = columnMinMax(empty, 4, 0);
|
|
CHECK(z.min == 0.0f && z.max == 0.0f);
|
|
|
|
// innerW <= 0 → {0, 0}.
|
|
ChannelEnvelope env = makeEnvelope({0.3f}, {0.7f});
|
|
MinMax z2 = columnMinMax(env, 0, 0);
|
|
CHECK(z2.min == 0.0f && z2.max == 0.0f);
|
|
MinMax z3 = columnMinMax(env, -1, 0);
|
|
CHECK(z3.min == 0.0f && z3.max == 0.0f);
|
|
}
|
|
|
|
static void testColumnMinMaxFullCoverageNoBlanks() {
|
|
// The critical gap-free property: for any bins/innerW ratio, every pixel column
|
|
// in [0, innerW) returns a non-zero-width or valid result (no column is skipped).
|
|
// Use 6 bins over 10 pixel columns (non-integer ratio). Every column must return
|
|
// the min/max of at least one bin (not the default {0,0} that would indicate a gap).
|
|
ChannelEnvelope env = makeEnvelope({0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f},
|
|
{0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f});
|
|
for (int col = 0; col < 10; ++col) {
|
|
MinMax mm = columnMinMax(env, 10, col);
|
|
// Every column must have a real bin value, not zero (all bins have positive values).
|
|
CHECK(mm.min >= 0.1f && mm.max <= 0.7f);
|
|
CHECK(mm.min <= mm.max);
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
testFrameToXEndpoints();
|
|
testFrameToXClampsOutOfRange();
|
|
testFrameToXDegenerate();
|
|
testXToFrameInverse();
|
|
testXToFrameClampsOutside();
|
|
testFrameToXRoundTrip();
|
|
|
|
testMarkerAtPointGrabsWithinBand();
|
|
testMarkerAtPointMissesBetween();
|
|
testMarkerAtPointFirstMatchOnOverlap();
|
|
testMarkerAtPointRejectsNullEmpty();
|
|
|
|
testResolveDragFrameShift();
|
|
testResolveDragFrameClamps();
|
|
testResolveDragFrameRounds();
|
|
testResolveDragFrameDegenerate();
|
|
|
|
testZeroCrossingNearest();
|
|
testZeroCrossingSampleOnZero();
|
|
testZeroCrossingEquidistantTieToLower();
|
|
testZeroCrossingNoneKeepsTarget();
|
|
testZeroCrossingClampsTarget();
|
|
testZeroCrossingDegenerate();
|
|
|
|
testColumnMinMaxOneToOne();
|
|
testColumnMinMaxUpsample();
|
|
testColumnMinMaxDownsample();
|
|
testColumnMinMaxColClamp();
|
|
testColumnMinMaxDegenerate();
|
|
testColumnMinMaxFullCoverageNoBlanks();
|
|
|
|
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;
|
|
}
|