fix(waveform): 4x-oversampled min/max envelope, columnMinMax homed in peaks, panel routed through shared drawWaveform — one gap-free algorithm on all surfaces

This commit is contained in:
2026-07-27 19:09:50 -04:00
parent 20308c842e
commit b3c9fad9ba
11 changed files with 240 additions and 203 deletions
+112 -1
View File
@@ -6,7 +6,10 @@
// ramp envelope monotonic across bins; DC/silence -> min==max; multi-channel
// independence (no fold); short buffer (fewer frames than bins) and non-divisible
// length (remainder bin); binCount==1 whole-buffer envelope; zero frames / zero
// channels / binCount==0 degenerate inputs.
// channels / binCount==0 degenerate inputs. Plus columnMinMax (the display-side
// per-pixel-column collapse, FA3): 1:1 passthrough, upsample fallback, downsample
// merge, steep disjoint-span merge, no-bin-dropped spike sweep, no-column-empty
// coverage, col clamp, degenerate inputs.
#include "../src/peaks.h"
@@ -348,6 +351,106 @@ static void testLastFrameDegenerate() {
CHECK(lastFrameAboveThreshold(buf, 2, 100, 0.1f) == 1);
}
// --- columnMinMax (display-side per-pixel-column collapse, FA3) ----------------
// 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 testColumnOneToOne() {
// 4 bins, 4 columns: each column is a pure passthrough of its one bin.
ChannelEnvelope env = makeEnvelope({-1.0f, -0.5f, 0.0f, 0.5f},
{ 0.5f, 0.0f, 0.5f, 1.0f});
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 testColumnUpsampleFallback() {
// 2 bins, 4 columns: columns 0,1 fall back to enclosing bin 0; 2,3 to bin 1 —
// no column left empty when there are more columns than bins.
ChannelEnvelope env = makeEnvelope({-1.0f, 0.5f}, {0.0f, 1.0f});
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 testColumnDownsampleMerge() {
// 4 bins, 2 columns: each column is the true min/max union of its 2 bins.
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 testColumnSteepDisjointSpans() {
// THE anti-alias case: two adjacent bins holding DISJOINT spans (a steep edge —
// all-positive then all-negative). Merged into one column the result must bridge
// both extremes as one full span {-1.0, 1.0}. The old per-bin overdraw could never
// produce a wrong value here — only the merge path exercises this.
ChannelEnvelope env = makeEnvelope({0.9f, -1.0f}, {1.0f, -0.9f});
const MinMax mm = columnMinMax(env, 1, 0);
CHECK(mm.min == -1.0f && mm.max == 1.0f);
}
static void testColumnNoBinDropped() {
// Downsample coverage: EVERY bin must land in some column (union of columns ==
// union of bins). For several non-integer ratios, plant a lone {-1,+1} spike in
// bin j (all other bins {0,0}) and assert some column reports it — a partition
// that skipped bin j would lose the spike entirely.
const struct { int nbins; int cols; } cases[] = {{7, 3}, {10, 4}, {9, 2}, {6, 10}};
for (const auto& c : cases) {
for (int j = 0; j < c.nbins; ++j) {
ChannelEnvelope env(static_cast<std::size_t>(c.nbins)); // all {0,0}
env[static_cast<std::size_t>(j)] = MinMax{-1.0f, 1.0f};
bool found = false;
for (int col = 0; col < c.cols; ++col) {
const MinMax mm = columnMinMax(env, c.cols, col);
if (mm.min == -1.0f && mm.max == 1.0f) { found = true; break; }
}
CHECK(found);
}
}
}
static void testColumnNoColumnEmpty() {
// Gap-free coverage: 6 bins over 10 columns (non-integer ratio) — every column
// must carry a real bin's values (all bins strictly positive, so a default {0,0}
// would expose a skipped column).
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) {
const MinMax mm = columnMinMax(env, 10, col);
CHECK(mm.min >= 0.1f && mm.max <= 0.7f);
CHECK(mm.min <= mm.max);
}
}
static void testColumnColClamp() {
// col outside [0, columnCount-1] clamps: negative to the first, large to the last.
ChannelEnvelope env = makeEnvelope({-0.5f, 0.5f}, {-0.1f, 0.9f});
CHECK(columnMinMax(env, 2, -5).min == -0.5f);
CHECK(columnMinMax(env, 2, 999).max == 0.9f);
}
static void testColumnDegenerate() {
// Empty envelope, columnCount <= 0 -> {0, 0}.
ChannelEnvelope empty;
const MinMax z = columnMinMax(empty, 4, 0);
CHECK(z.min == 0.0f && z.max == 0.0f);
ChannelEnvelope env = makeEnvelope({0.3f}, {0.7f});
const MinMax z2 = columnMinMax(env, 0, 0);
CHECK(z2.min == 0.0f && z2.max == 0.0f);
const MinMax z3 = columnMinMax(env, -1, 0);
CHECK(z3.min == 0.0f && z3.max == 0.0f);
}
int main() {
testSineEnvelope();
testRampMonotonic();
@@ -364,6 +467,14 @@ int main() {
testLastFrameAllAbove();
testLastFramePerChannelMaxAbs();
testLastFrameDegenerate();
testColumnOneToOne();
testColumnUpsampleFallback();
testColumnDownsampleMerge();
testColumnSteepDisjointSpans();
testColumnNoBinDropped();
testColumnNoColumnEmpty();
testColumnColClamp();
testColumnDegenerate();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
+1 -91
View File
@@ -7,8 +7,7 @@
// 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).
// no-crossing keeps target, target clamp, degenerate buffers).
#include "../src/vst/waveform_view.h"
@@ -17,8 +16,6 @@
using namespace reasampler::vst;
using reasampler::AudioSample;
using reasampler::MinMax;
using reasampler::ChannelEnvelope;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
@@ -195,86 +192,6 @@ static void testZeroCrossingDegenerate() {
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();
@@ -300,13 +217,6 @@ int main() {
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;