b7b7e88195
Ctrl during a marker drag defeats the snap outright. nearestZeroCrossing keeps its unbounded contract; snapToZeroCrossing is the decision a drag applies.
989 lines
49 KiB
C++
989 lines
49 KiB
C++
// Standalone tests for reasampler::instrument::ui::waveform_view — no VST3, no REAPER, no framework.
|
|
// Same fast assert loop as the sibling pure tests. Assert the waveform band's drawn surface
|
|
// (lane split + the full-height overlay contract) and its frame<->pixel mapping, marker grab
|
|
// regions, drag-delta frame resolver (with clamps), and zero-crossing snap.
|
|
//
|
|
// Covers: frameToX / xToFrame — the ONE map, asserted against the REAL draw chain
|
|
// (computeEnvelope + columnMinMax) rather than a restatement of it, at frame 0 / the last frame
|
|
// / an interior frame and then exhaustively, in both the frames>columns and frames<columns
|
|
// regimes, plus the exclusive span end, both round trips, edge clamps and degenerate inputs;
|
|
// waveformOverlayArea (the overlay IS the drawn column band, inset symmetrically);
|
|
// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection);
|
|
// markerHandleRect (the top-strip tab that keeps coincident markers independently grabbable);
|
|
// resolveDragFrame (drag lands on the frameToX/xToFrame column under the cursor, 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); snapToZeroCrossing + zeroCrossingSnapFrames (the radius: a single-cycle mark stays
|
|
// where it was dropped, dense material answers exactly what the unbounded search did, the
|
|
// boundary either side, the tie rule inside it, a sub-frame-per-pixel radius, clamps and
|
|
// degenerate buffers); the four marks (per-mark cap
|
|
// resolve, the reverse cap order that keeps a coincident pair separable, label sides/nudging,
|
|
// the suppression rule and its promoted-first placement, the crossfade wedge ramp);
|
|
// waveformSurface (two stacked
|
|
// lanes L-over-R in stereo, one lane in mono AND for a mono source, overlay always the full
|
|
// stacked height, grabs reaching the lower lane); laneEnvelope (per-lane channel split).
|
|
|
|
#include "../src/core/instrument/ui/waveform_view.h"
|
|
#include "../src/core/instrument/ui/sample_bands.h" // kWaveformMinHeight, kLaneGap
|
|
#include "../src/core/ui/component_geometry.h" // waveformColumnCount (the draw chain's own)
|
|
|
|
#include <cmath>
|
|
#include <cstddef>
|
|
#include <cstdio>
|
|
#include <vector>
|
|
|
|
using namespace reasampler;
|
|
using namespace reasampler::instrument::ui;
|
|
using reasampler::audio::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)
|
|
|
|
static OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; }
|
|
|
|
// A comfortable waveform area: 1000px wide, offset so left != 0 (catches origin bugs).
|
|
static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 90); } // width 1000
|
|
|
|
// --- frameToX / xToFrame ------------------------------------------------------
|
|
|
|
// 1000 frames over 1000 columns: each frame owns exactly one column, so the map is the
|
|
// identity and every endpoint is exact.
|
|
static void testFrameToXEndpoints() {
|
|
const Rect a = wideArea();
|
|
CHECK(frameToX(overlayOf(a), 1000, 0) == a.x); // frame 0 -> first column
|
|
CHECK(frameToX(overlayOf(a), 1000, 999) == a.right() - 1); // last FRAME -> last column
|
|
CHECK(frameToX(overlayOf(a), 1000, 1000) == a.right()); // the exclusive span end -> past it
|
|
CHECK(frameToX(overlayOf(a), 1000, 500) == a.x + 500); // midpoint (1:1 here)
|
|
}
|
|
|
|
static void testFrameToXClampsOutOfRange() {
|
|
const Rect a = wideArea();
|
|
CHECK(frameToX(overlayOf(a), 1000, -50) == a.x); // below 0 pins left
|
|
CHECK(frameToX(overlayOf(a), 1000, 5000) == a.right()); // above count pins right
|
|
}
|
|
|
|
static void testFrameToXDegenerate() {
|
|
const Rect a = wideArea();
|
|
CHECK(frameToX(overlayOf(a), 0, 100) == a.x); // no frames -> left
|
|
const Rect z = Rect::ltrb(5, 5, 5, 45); // zero width
|
|
CHECK(frameToX(overlayOf(z), 1000, 500) == z.x);
|
|
}
|
|
|
|
static void testXToFrameInverse() {
|
|
const Rect a = wideArea();
|
|
CHECK(xToFrame(overlayOf(a), 1000, a.x) == 0);
|
|
CHECK(xToFrame(overlayOf(a), 1000, a.right()) == 1000);
|
|
CHECK(xToFrame(overlayOf(a), 1000, a.x + 250) == 250); // 1:1 map here
|
|
}
|
|
|
|
static void testXToFrameClampsOutside() {
|
|
const Rect a = wideArea();
|
|
CHECK(xToFrame(overlayOf(a), 1000, a.x - 100) == 0); // left of area -> 0
|
|
CHECK(xToFrame(overlayOf(a), 1000, a.right() + 100) == 1000); // right of area -> frameCount
|
|
CHECK(xToFrame(overlayOf(a), 0, a.x + 10) == 0); // no frames -> 0
|
|
}
|
|
|
|
// --- The frame<->pixel mapping against the draw chain it must agree with -------
|
|
//
|
|
// The whole Ω.6 contract: the overlay reads the SAME frame->column partition the waveform is
|
|
// binned and drawn through, so these fixtures run the REAL chain (computeEnvelope +
|
|
// columnMinMax) rather than restating the partition, which would only prove the test agrees
|
|
// with itself.
|
|
|
|
// Which columns the draw chain actually paints frame `f` into: a spike at f over silence, binned
|
|
// exactly as paintWaveform bins it, read back per column. Inclusive run, or lo < 0 for none.
|
|
struct ColumnRun { int lo = -1; int hi = -1; };
|
|
|
|
static ColumnRun drawnColumnsForFrame(int columns, std::int64_t frameCount, std::int64_t f) {
|
|
std::vector<AudioSample> pcm(static_cast<std::size_t>(frameCount), 0.0f);
|
|
pcm[static_cast<std::size_t>(f)] = 1.0f;
|
|
// paintWaveform's own bin count: one per drawn column, capped at the frames available.
|
|
const std::int64_t wantBins = static_cast<std::int64_t>(columns);
|
|
const std::size_t bins =
|
|
static_cast<std::size_t>(wantBins < frameCount ? wantBins : frameCount);
|
|
const reasampler::audio::Envelope env =
|
|
reasampler::audio::computeEnvelope(pcm, 1, static_cast<std::size_t>(frameCount), bins);
|
|
ColumnRun run;
|
|
for (int c = 0; c < columns; ++c) {
|
|
if (reasampler::audio::columnMinMax(env[0], columns, c).max < 1.0f) continue;
|
|
if (run.lo < 0) run.lo = c;
|
|
run.hi = c;
|
|
}
|
|
return run;
|
|
}
|
|
|
|
static void checkMarkLandsOnItsOwnWaveformColumn(const Rect& band, std::int64_t frameCount,
|
|
std::int64_t f) {
|
|
const OverlayArea ov = waveformOverlayArea(band);
|
|
const ColumnRun run = drawnColumnsForFrame(ov.rect.width, frameCount, f);
|
|
CHECK(run.lo >= 0); // the draw chain paints every frame somewhere
|
|
const int col = frameToX(ov, frameCount, f) - ov.rect.x;
|
|
CHECK(col >= run.lo && col <= run.hi);
|
|
}
|
|
|
|
static void testAMarkLandsOnTheWaveformColumnForItsOwnFrame() {
|
|
const Rect b = Rect{8, 90, 404, 60}; // 400 drawn columns
|
|
// frames > columns: many frames share one column, and the mark must pick that column.
|
|
const std::int64_t many = 9973; // prime, so no boundary falls anywhere convenient
|
|
checkMarkLandsOnItsOwnWaveformColumn(b, many, 0);
|
|
checkMarkLandsOnItsOwnWaveformColumn(b, many, many - 1);
|
|
checkMarkLandsOnItsOwnWaveformColumn(b, many, 4001);
|
|
// frames < columns: one frame spans many columns, and the mark must land inside its own run.
|
|
const std::int64_t few = 37;
|
|
checkMarkLandsOnItsOwnWaveformColumn(b, few, 0);
|
|
checkMarkLandsOnItsOwnWaveformColumn(b, few, few - 1);
|
|
checkMarkLandsOnItsOwnWaveformColumn(b, few, 19);
|
|
}
|
|
|
|
// Not just the three probe frames: EVERY frame, across both regimes and the 1:1 boundary.
|
|
static void testTheMappingAgreesWithTheDrawChainAtEveryFrame() {
|
|
const int widths[] = {21, 64, 104}; // 17 / 60 / 100 drawn columns
|
|
const std::int64_t counts[] = {7, 60, 100, 251}; // below, equal to and above each
|
|
for (int w : widths) {
|
|
for (std::int64_t n : counts) {
|
|
const Rect b = Rect{3, 0, w, 40};
|
|
for (std::int64_t f = 0; f < n; ++f) checkMarkLandsOnItsOwnWaveformColumn(b, n, f);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The closed domain is a SPAN's exclusive end, not a frame: it is what the loop fill and the
|
|
// crossfade wedge stop at, so it belongs one past the last column and nowhere else.
|
|
static void testTheExclusiveSpanEndLandsOnTheRightEdge() {
|
|
const Rect b = Rect{8, 90, 404, 60};
|
|
const OverlayArea ov = waveformOverlayArea(b);
|
|
const std::int64_t counts[] = {7, 400, 9973};
|
|
for (std::int64_t n : counts) {
|
|
CHECK(frameToX(ov, n, n) == ov.rect.right());
|
|
CHECK(frameToX(ov, n, n + 5000) == ov.rect.right()); // and clamps there
|
|
// The last real FRAME is the last real column — one inside that edge.
|
|
CHECK(frameToX(ov, n, n - 1) == ov.rect.right() - 1);
|
|
}
|
|
}
|
|
|
|
static void testXToFrameRoundTripsEveryFrameWhileAFrameOwnsAColumn() {
|
|
// frames <= columns is exactly where a frame spans several columns and the choice of which
|
|
// one to mark is observable, so it is where the inverse has to be exact.
|
|
const Rect b = Rect{8, 90, 404, 60};
|
|
const OverlayArea ov = waveformOverlayArea(b);
|
|
const std::int64_t counts[] = {1, 37, 399, 400};
|
|
for (std::int64_t n : counts) {
|
|
for (std::int64_t f = 0; f < n; ++f) CHECK(xToFrame(ov, n, frameToX(ov, n, f)) == f);
|
|
}
|
|
}
|
|
|
|
static void testColumnsRoundTripWhereFramesShareThem() {
|
|
// Above the column count a per-frame round trip cannot exist — several frames share one
|
|
// column. What must still hold is the COLUMN round trip: every column answers a frame that
|
|
// maps straight back to that same column, so no column is unreachable or ambiguous.
|
|
const Rect b = Rect{8, 90, 404, 60};
|
|
const OverlayArea ov = waveformOverlayArea(b);
|
|
const std::int64_t n = 9973;
|
|
for (int c = 0; c < ov.rect.width; ++c) {
|
|
const int x = ov.rect.x + c;
|
|
CHECK(frameToX(ov, n, xToFrame(ov, n, x)) == x);
|
|
}
|
|
}
|
|
|
|
// --- markerAtPoint ------------------------------------------------------------
|
|
|
|
static void testMarkerAtPointGrabsWithinBand() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
// 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.y + a.height / 2;
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 100, midY) == 0);
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 500, midY) == 1);
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 900, midY) == 2);
|
|
// Within the grab band on either side of the line.
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 500 + kMarkerGrabWidth, midY) == 1);
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 500 - kMarkerGrabWidth, midY) == 1);
|
|
}
|
|
|
|
static void testMarkerAtPointMissesBetween() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
const std::int64_t frames[3] = {100, 500, 900};
|
|
const int midY = a.y + a.height / 2;
|
|
// Well away from any marker line.
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 300, midY) == -1);
|
|
// Off the area vertically.
|
|
CHECK(markerAtPoint(ov, 1000, frames, 3, a.x + 500, a.y - 5) == -1);
|
|
}
|
|
|
|
static void testMarkerAtPointFirstMatchOnOverlap() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
// Two markers at the same frame -> first in order wins.
|
|
const std::int64_t frames[2] = {400, 400};
|
|
const int midY = a.y + a.height / 2;
|
|
CHECK(markerAtPoint(ov, 1000, frames, 2, a.x + 400, midY) == 0);
|
|
}
|
|
|
|
static void testMarkerAtPointRejectsNullEmpty() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
const int midY = a.y + a.height / 2;
|
|
CHECK(markerAtPoint(ov, 1000, nullptr, 3, a.x + 100, midY) == -1);
|
|
const std::int64_t frames[1] = {100};
|
|
CHECK(markerAtPoint(ov, 1000, frames, 0, a.x + 100, midY) == -1);
|
|
}
|
|
|
|
// --- resolveDragFrame ---------------------------------------------------------
|
|
|
|
static void testResolveDragFrameShift() {
|
|
const Rect a = wideArea(); // 1:1 (1000px / 1000 frames)
|
|
const OverlayArea ov = overlayOf(a);
|
|
CHECK(resolveDragFrame(ov, 1000, 300, 0) == 300); // zero delta -> unchanged
|
|
CHECK(resolveDragFrame(ov, 1000, 300, 100) == 400); // +100px -> +100 frames
|
|
CHECK(resolveDragFrame(ov, 1000, 300, -50) == 250); // -50px -> -50 frames
|
|
}
|
|
|
|
static void testResolveDragFrameClamps() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
CHECK(resolveDragFrame(ov, 1000, 50, -500) == 0); // clamp low
|
|
CHECK(resolveDragFrame(ov, 1000, 950, 500) == 1000); // clamp high (== frameCount)
|
|
}
|
|
|
|
static void testResolveDragFrameTruncatesAtFractionalScale() {
|
|
// 300px area over 1000 frames -> 3.33 frames/px, so frameToX(start) -> +dx -> xToFrame
|
|
// lands on the column's truncating partition rather than a whole multiple of dx.
|
|
const OverlayArea ov = overlayOf(Rect::ltrb(0, 0, 300, 60));
|
|
// frameToX(100) = 30; xToFrame(30 + 3) = 33*1000/300 = 110.0 -> 110 frames.
|
|
CHECK(resolveDragFrame(ov, 1000, 100, 3) == 110);
|
|
// frameToX(100) = 30; xToFrame(30 + 1) = 31*1000/300 = 103.33 -> truncates to 103.
|
|
CHECK(resolveDragFrame(ov, 1000, 100, 1) == 103);
|
|
}
|
|
|
|
static void testResolveDragFrameDegenerate() {
|
|
const Rect z = Rect::ltrb(0, 0, 0, 60); // zero width
|
|
CHECK(resolveDragFrame(overlayOf(z), 1000, 300, 100) == 300); // pinned to start
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
CHECK(resolveDragFrame(ov, 0, 300, 100) == 0); // no frames -> clamp(start)=0
|
|
// startFrame out of range is clamped first.
|
|
CHECK(resolveDragFrame(ov, 1000, 5000, 0) == 1000);
|
|
}
|
|
|
|
// A drag must land on the column under the cursor — the SAME frameToX/xToFrame partition, never
|
|
// a proportional approximation of it. Pins the contract itself (grabX = frameToX(startFrame),
|
|
// result = xToFrame(grabX + dxPixels)) rather than a captured number, in the frames < columns
|
|
// regime where the two disagree: a prior independent linear map here left a marker at frame 10
|
|
// (1000px/37 frames, start=10, +3px) when a fresh xToFrame(x) at the same cursor column
|
|
// resolves to frame 11 — exactly the class of drift a second frame<->pixel map produces.
|
|
static void testResolveDragFrameLandsOnCursorColumn() {
|
|
const OverlayArea ov = overlayOf(Rect::ltrb(0, 0, 1000, 60));
|
|
const std::int64_t frameCount = 37;
|
|
const std::int64_t startFrame = 10;
|
|
const int dx = 3;
|
|
const int grabX = frameToX(ov, frameCount, startFrame);
|
|
const std::int64_t cursorFrame = xToFrame(ov, frameCount, grabX + dx);
|
|
CHECK(cursorFrame == 11); // the contract's own derivation
|
|
CHECK(resolveDragFrame(ov, frameCount, startFrame, dx) == cursorFrame);
|
|
// Structural coverage, not behavioural: this sweep asserts resolveDragFrame's own definition
|
|
// (frameToX then xToFrame) and cannot fail while it calls those two functions. Its value is
|
|
// pinning that there is no second, independent mapping hiding in some frames<columns or
|
|
// frames>columns corner — the single behavioural anchor is the literal 11 above.
|
|
const std::int64_t counts[] = {5, 37, 251, 9973};
|
|
const int deltas[] = {-97, -3, -1, 1, 3, 97};
|
|
for (std::int64_t n : counts) {
|
|
for (std::int64_t start = 0; start < n; start += (std::max<std::int64_t>)(1, n / 11)) {
|
|
for (int d : deltas) {
|
|
const std::int64_t got = resolveDragFrame(ov, n, start, d);
|
|
const int wantGrabX = frameToX(ov, n, start);
|
|
CHECK(got == xToFrame(ov, n, wantGrabX + d));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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);
|
|
}
|
|
|
|
// The snap has to survive the mapping change BEHAVIOUR-IDENTICAL, ties included, so the tie
|
|
// rule is pinned at every distance rather than at one: the fan-out probes t-d before t+d, so an
|
|
// equidistant pair always resolves to the LOWER frame. A single spike to 0 is its own isolated
|
|
// crossing (the sample-on-zero rule), which is what keeps each side's crossing count at one.
|
|
static void testZeroCrossingTiesAlwaysResolveToTheLowerFrame() {
|
|
const std::int64_t n = 200, t = 100;
|
|
for (std::int64_t d = 1; d <= 40; ++d) {
|
|
std::vector<AudioSample> pcm(static_cast<std::size_t>(n), 1.0f);
|
|
pcm[static_cast<std::size_t>(t - d)] = 0.0f;
|
|
pcm[static_cast<std::size_t>(t + d)] = 0.0f;
|
|
CHECK(nearestZeroCrossing(pcm.data(), n, t) == t - d);
|
|
}
|
|
}
|
|
|
|
// ...and the tie rule is the ONLY asymmetry: wherever one side is strictly nearer, that side
|
|
// wins, from either direction. Without this, "lower wins" could hide a left-biased search.
|
|
static void testZeroCrossingTakesTheNearerSideFromEitherDirection() {
|
|
const std::int64_t n = 200, t = 100;
|
|
for (std::int64_t d = 2; d <= 40; ++d) {
|
|
{
|
|
std::vector<AudioSample> pcm(static_cast<std::size_t>(n), 1.0f);
|
|
pcm[static_cast<std::size_t>(t - d)] = 0.0f;
|
|
pcm[static_cast<std::size_t>(t + d - 1)] = 0.0f; // right nearer by one
|
|
CHECK(nearestZeroCrossing(pcm.data(), n, t) == t + d - 1);
|
|
}
|
|
{
|
|
std::vector<AudioSample> pcm(static_cast<std::size_t>(n), 1.0f);
|
|
pcm[static_cast<std::size_t>(t - d + 1)] = 0.0f; // left nearer by one
|
|
pcm[static_cast<std::size_t>(t + d)] = 0.0f;
|
|
CHECK(nearestZeroCrossing(pcm.data(), n, t) == t - d + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// --- snapToZeroCrossing: the radius -------------------------------------------
|
|
|
|
// The radius in frames is the frame span kZeroCrossingSnapPx pixels cover, so it tracks the
|
|
// capture's length against a fixed band — read off xToFrame, never a second ratio.
|
|
static void testSnapRadiusIsThePixelBandsOwnFrameSpan() {
|
|
const Rect a = wideArea(); // width 1000
|
|
CHECK(zeroCrossingSnapFrames(overlayOf(a), 100000) == kZeroCrossingSnapPx * 100);
|
|
CHECK(zeroCrossingSnapFrames(overlayOf(a), 1000) == kZeroCrossingSnapPx); // 1 frame per px
|
|
// Below one frame per pixel the radius is 0: the user is placing individual frames.
|
|
CHECK(zeroCrossingSnapFrames(overlayOf(a), 100) == 0);
|
|
CHECK(zeroCrossingSnapFrames(overlayOf(a), 0) == 0);
|
|
CHECK(zeroCrossingSnapFrames(overlayOf(Rect::ltrb(0, 0, 0, 60)), 1000) == 0); // zero width
|
|
}
|
|
|
|
// One cycle of a 60 Hz sine at 48 kHz — 800 frames, and exactly ONE interior sign change, at the
|
|
// midpoint (frame 0 is on zero, which is not a crossing, and the up-crossing is the wrap). That
|
|
// single crossing IS the reported defect: an unbounded search resolves every drop in the buffer
|
|
// to it, so the loop can only ever be half a cycle.
|
|
static std::vector<AudioSample> singleCycleSine() {
|
|
constexpr std::int64_t n = 800;
|
|
constexpr double kTwoPi = 6.283185307179586;
|
|
std::vector<AudioSample> pcm(static_cast<std::size_t>(n));
|
|
for (std::int64_t i = 0; i < n; ++i) {
|
|
const double phase = kTwoPi * static_cast<double>(i) / static_cast<double>(n);
|
|
pcm[static_cast<std::size_t>(i)] = static_cast<AudioSample>(std::sin(phase));
|
|
}
|
|
return pcm;
|
|
}
|
|
|
|
static void testSnapLeavesASingleCycleMarkWhereItWasDropped() {
|
|
const std::vector<AudioSample> pcm = singleCycleSine();
|
|
const std::int64_t n = static_cast<std::int64_t>(pcm.size());
|
|
// The fixture really does teleport under the unbounded search — both quadrant peaks land on
|
|
// the one midpoint crossing, hundreds of frames away.
|
|
CHECK(nearestZeroCrossing(pcm.data(), n, 200) == 401);
|
|
CHECK(nearestZeroCrossing(pcm.data(), n, 600) == 401);
|
|
|
|
const Rect a = Rect::ltrb(20, 10, 820, 90); // 800 px for 800 frames -> 1 frame per px
|
|
const std::int64_t r = zeroCrossingSnapFrames(overlayOf(a), n);
|
|
CHECK(r == kZeroCrossingSnapPx);
|
|
// ...and with the radius the marks stay put, which is what makes the loop draggable at all.
|
|
CHECK(snapToZeroCrossing(pcm.data(), n, 200, r) == 200);
|
|
CHECK(snapToZeroCrossing(pcm.data(), n, 600, r) == 600);
|
|
// The snap is not dead here — aimed at the crossing it still takes it.
|
|
CHECK(snapToZeroCrossing(pcm.data(), n, 401 - r, r) == 401);
|
|
CHECK(snapToZeroCrossing(pcm.data(), n, 401 - r - 1, r) == 401 - r - 1);
|
|
}
|
|
|
|
// Dense material: 48000 frames flipping sign every 24 (a 1 kHz square), drawn 1000 px wide, so
|
|
// the radius is 240 frames and every crossing is within 12. The snap must therefore answer
|
|
// exactly what the unbounded search always did, at every target — long material does not change.
|
|
static void testSnapIsUnchangedOnDenseMaterial() {
|
|
constexpr std::int64_t n = 48000, kHalfPeriod = 24;
|
|
std::vector<AudioSample> pcm(static_cast<std::size_t>(n));
|
|
for (std::int64_t i = 0; i < n; ++i) {
|
|
pcm[static_cast<std::size_t>(i)] = ((i / kHalfPeriod) % 2 == 0) ? 1.0f : -1.0f;
|
|
}
|
|
const Rect a = wideArea(); // width 1000 -> 48 frames per px
|
|
const std::int64_t r = zeroCrossingSnapFrames(overlayOf(a), n);
|
|
CHECK(r == kZeroCrossingSnapPx * 48);
|
|
// A mark dropped one frame off a crossing still snaps onto it.
|
|
CHECK(snapToZeroCrossing(pcm.data(), n, kHalfPeriod + 1, r) == kHalfPeriod);
|
|
CHECK(snapToZeroCrossing(pcm.data(), n, kHalfPeriod - 1, r) == kHalfPeriod);
|
|
for (std::int64_t t = 0; t < n; t += 7) {
|
|
CHECK(snapToZeroCrossing(pcm.data(), n, t, r) == nearestZeroCrossing(pcm.data(), n, t));
|
|
}
|
|
}
|
|
|
|
// The boundary, both sides: exactly at the radius is inside it, one past it is not. A lone 0.0
|
|
// sample is its own isolated crossing (the sample-on-zero rule), so each buffer has exactly one.
|
|
static void testSnapTakesACrossingAtTheRadiusAndRefusesOnePastIt() {
|
|
const std::int64_t n = 400, t = 200, r = 10;
|
|
for (const std::int64_t at : {t + r, t + r + 1, t - r, t - r - 1}) {
|
|
std::vector<AudioSample> pcm(static_cast<std::size_t>(n), 1.0f);
|
|
pcm[static_cast<std::size_t>(at)] = 0.0f;
|
|
const std::int64_t want = (at == t + r || at == t - r) ? at : t;
|
|
CHECK(snapToZeroCrossing(pcm.data(), n, t, r) == want);
|
|
}
|
|
}
|
|
|
|
// The tie rule is the radius's too: inside it, the fan-out order still decides, and it still
|
|
// resolves to the LOWER frame at every distance.
|
|
static void testSnapKeepsTheTieRuleInsideTheRadius() {
|
|
const std::int64_t n = 200, t = 100, r = 40;
|
|
for (std::int64_t d = 1; d <= r; ++d) {
|
|
std::vector<AudioSample> pcm(static_cast<std::size_t>(n), 1.0f);
|
|
pcm[static_cast<std::size_t>(t - d)] = 0.0f;
|
|
pcm[static_cast<std::size_t>(t + d)] = 0.0f;
|
|
CHECK(snapToZeroCrossing(pcm.data(), n, t, r) == t - d);
|
|
}
|
|
}
|
|
|
|
static void testSnapAtZeroRadiusMovesNothingButAnExactHit() {
|
|
std::vector<AudioSample> pcm = {1, 1, -1, -1}; // crossing at 2
|
|
CHECK(snapToZeroCrossing(pcm.data(), 4, 2, 0) == 2);
|
|
CHECK(snapToZeroCrossing(pcm.data(), 4, 1, 0) == 1);
|
|
CHECK(snapToZeroCrossing(pcm.data(), 4, 3, 0) == 3);
|
|
}
|
|
|
|
static void testSnapClampsAndTakesDegenerateInputs() {
|
|
std::vector<AudioSample> pcm = {1, -1, 1, -1}; // crossings at 1,2,3
|
|
CHECK(snapToZeroCrossing(pcm.data(), 4, 999, 100) == 3); // clamped, then found
|
|
CHECK(snapToZeroCrossing(pcm.data(), 4, -999, 100) == 1);
|
|
CHECK(snapToZeroCrossing(pcm.data(), 4, 0, 100) == 1); // frame 0 is never a crossing
|
|
CHECK(snapToZeroCrossing(pcm.data(), 4, 0, -1) == 0); // negative radius -> no snap
|
|
CHECK(snapToZeroCrossing(nullptr, 0, 5, 100) == 0);
|
|
std::vector<AudioSample> one = {1};
|
|
CHECK(snapToZeroCrossing(one.data(), 1, 0, 100) == 0); // <2 frames -> clamped target
|
|
}
|
|
|
|
// --- waveformSurface: the lane split + the overlay contract --------------------
|
|
|
|
// A realistic waveform band: full-width, taller than the two-lane floor.
|
|
static Rect band() { return Rect::ltrb(8, 90, 832, 90 + kWaveformMinHeight); }
|
|
|
|
static void testSurfaceStereoStacksTwoLanes() {
|
|
const Rect b = band();
|
|
const WaveformSurface s = waveformSurface(b, /*stereoMode=*/true, /*sourceChannels=*/2);
|
|
CHECK(s.laneCount == 2);
|
|
CHECK(!s.upper.empty() && !s.lower.empty());
|
|
CHECK(s.upper.y == b.y); // L on top
|
|
CHECK(s.lower.y > s.upper.bottom()); // R below, seam between them
|
|
CHECK(s.lower.bottom() == b.bottom()); // together they reach the band's floor
|
|
CHECK(s.upper.x == b.x && s.upper.width == b.width);
|
|
CHECK(s.lower.x == b.x && s.lower.width == b.width);
|
|
// Non-overlapping, and the band is exactly lanes + the one seam gap.
|
|
CHECK(s.lower.y - s.upper.bottom() == kLaneGap);
|
|
CHECK(s.upper.height + kLaneGap + s.lower.height == b.height);
|
|
}
|
|
|
|
static void testSurfaceMonoIsOneLane() {
|
|
const Rect b = band();
|
|
const WaveformSurface s = waveformSurface(b, /*stereoMode=*/false, /*sourceChannels=*/2);
|
|
CHECK(s.laneCount == 1);
|
|
CHECK(s.upper == b); // the single lane spans the whole band
|
|
CHECK(s.lower.empty()); // no second lane to draw
|
|
}
|
|
|
|
static void testSurfaceMonoSourceInStereoModeStaysOneLane() {
|
|
// Dual-mono: a mono source under stereo mode has no second channel, so a second lane
|
|
// would be a redundant duplicate.
|
|
const Rect b = band();
|
|
const WaveformSurface s = waveformSurface(b, /*stereoMode=*/true, /*sourceChannels=*/1);
|
|
CHECK(s.laneCount == 1);
|
|
CHECK(s.upper == b);
|
|
CHECK(s.lower.empty());
|
|
}
|
|
|
|
static void testSurfaceOverlayIsFullStackedHeightInBothModes() {
|
|
const Rect b = band();
|
|
const WaveformSurface st = waveformSurface(b, /*stereoMode=*/true, 2);
|
|
const WaveformSurface mo = waveformSurface(b, /*stereoMode=*/false, 2);
|
|
// Stereo: ONE overlay rect spanning both lanes, not either lane. The HEIGHT is what the
|
|
// overlay contract is about, and it is the whole stack in both modes.
|
|
CHECK(st.overlay.rect.y == b.y && st.overlay.rect.height == b.height);
|
|
CHECK(st.overlay.rect.height == st.upper.height + kLaneGap + st.lower.height);
|
|
CHECK(st.overlay.rect != st.upper && st.overlay.rect != st.lower);
|
|
// Mono: the same rect. It is NOT the single lane any more — the lane is the whole band,
|
|
// the overlay is the band's drawn column span inside it.
|
|
CHECK(mo.overlay.rect.y == b.y && mo.overlay.rect.height == b.height);
|
|
CHECK(mo.overlay.rect == st.overlay.rect);
|
|
CHECK(mo.overlay.rect != mo.upper);
|
|
// The standalone accessor the hit-test paths use agrees with the resolved surface.
|
|
CHECK(waveformOverlayArea(b) == st.overlay);
|
|
CHECK(waveformOverlayArea(b) == mo.overlay);
|
|
}
|
|
|
|
// THE Ω.6 contract at the construction site: the overlay is the band's drawn column span, so
|
|
// an overlay pixel and a waveform column are the same pixel. Read from the draw chain's own
|
|
// column count — a hardcoded 2/4 here would be the second copy that let the two drift.
|
|
static void testTheOverlayIsExactlyTheDrawnColumnBand() {
|
|
const Rect b = band();
|
|
const OverlayArea ov = waveformOverlayArea(b);
|
|
const int columns = reasampler::ui::waveformColumnCount(b);
|
|
CHECK(columns > 0);
|
|
CHECK(ov.rect.width == columns);
|
|
CHECK(ov.rect.x == b.x + (b.width - columns) / 2);
|
|
// Inset on BOTH sides, and the same amount on each — the halving above is only legitimate
|
|
// because the draw chain's inset is symmetric.
|
|
CHECK(ov.rect.x - b.x == b.right() - ov.rect.right());
|
|
CHECK(ov.rect.x > b.x && ov.rect.right() < b.right());
|
|
// Held across widths, not just this one.
|
|
for (int w = 5; w <= 300; ++w) {
|
|
const Rect band2 = Rect{7, 40, w, 60};
|
|
const OverlayArea o2 = waveformOverlayArea(band2);
|
|
CHECK(o2.rect.width == reasampler::ui::waveformColumnCount(band2));
|
|
CHECK(o2.rect.x - band2.x == band2.right() - o2.rect.right());
|
|
}
|
|
}
|
|
|
|
static void testSurfaceDegenerateBandDrawsNothing() {
|
|
const WaveformSurface s = waveformSurface(Rect{10, 10, 0, 0}, true, 2);
|
|
CHECK(s.laneCount == 0);
|
|
CHECK(s.upper.empty() && s.lower.empty() && s.overlay.rect.empty());
|
|
CHECK(waveformOverlayArea(Rect{10, 10, 0, 0}).rect.empty());
|
|
// A band too narrow to hold a single column has no overlay to draw into, even though the
|
|
// band itself is not degenerate and still gets a lane.
|
|
CHECK(reasampler::ui::waveformColumnCount(Rect{0, 0, 4, 40}) == 0);
|
|
CHECK(waveformOverlayArea(Rect{0, 0, 4, 40}).rect.empty());
|
|
CHECK(!waveformSurface(Rect{0, 0, 4, 40}, false, 1).upper.empty());
|
|
}
|
|
|
|
static void testSurfaceThinBandRoundsLowerLaneEmpty() {
|
|
// Height 3 is the edge where the stereo split's integer division rounds the lower lane to
|
|
// empty even though the band itself isn't degenerate — pins the laneCount derivation.
|
|
const WaveformSurface s = waveformSurface(Rect{0, 0, 100, 3}, true, 2);
|
|
CHECK(s.laneCount == 1);
|
|
CHECK(!s.upper.empty());
|
|
CHECK(s.lower.empty());
|
|
}
|
|
|
|
// --- Hit-testing across the stacked lanes -------------------------------------
|
|
|
|
static void testMarkerGrabReachesTheLowerStereoLane() {
|
|
const Rect b = band();
|
|
const WaveformSurface s = waveformSurface(b, /*stereoMode=*/true, 2);
|
|
const std::int64_t frames = 1000;
|
|
const std::int64_t markers[1] = {500};
|
|
const int mx = frameToX(s.overlay, frames, 500);
|
|
// The same marker answers a grab in either lane — overlays span the full stack.
|
|
const int upperY = s.upper.y + s.upper.height / 2;
|
|
const int lowerY = s.lower.y + s.lower.height / 2;
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, upperY) == 0);
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, lowerY) == 0);
|
|
// A lower-lane grab hit-tested against the UPPER LANE would be lost — the miss this
|
|
// contract exists to prevent. (Explicit OverlayArea{} wrap: production code can't do
|
|
// this by accident — markerAtPoint won't accept a bare lane Rect — but the geometry
|
|
// claim still needs proving.)
|
|
CHECK(markerAtPoint(overlayOf(s.upper), frames, markers, 1, mx, lowerY) == -1);
|
|
// Off the marker's x is still a miss at either height.
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx + 40, lowerY) == -1);
|
|
}
|
|
|
|
static void testMarkerGrabInMonoSpansTheBand() {
|
|
const Rect b = band();
|
|
const WaveformSurface s = waveformSurface(b, /*stereoMode=*/false, 2);
|
|
const std::int64_t frames = 1000;
|
|
const std::int64_t markers[1] = {250};
|
|
const int mx = frameToX(s.overlay, frames, 250);
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.y) == 0);
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.bottom() - 1) == 0);
|
|
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.bottom() + 5) == -1);
|
|
}
|
|
|
|
// --- The marker grab handle ----------------------------------------------------
|
|
|
|
static void testMarkerHandleIsATopStripCentredOnTheMarker() {
|
|
const Rect a = wideArea();
|
|
const int mx = frameToX(overlayOf(a), 1000, 250);
|
|
const Rect h = markerHandleRect(overlayOf(a), 1000, 250);
|
|
CHECK(h.x == mx - kMarkerHandleHalfWidth);
|
|
CHECK(h.right() == mx + kMarkerHandleHalfWidth + 1);
|
|
CHECK(h.y == a.y);
|
|
CHECK(h.height == kMarkerHandleHeight);
|
|
CHECK(contains(h, mx, a.y));
|
|
CHECK(contains(h, mx, a.y + kMarkerHandleHeight - 1));
|
|
CHECK(!contains(h, mx, a.y + kMarkerHandleHeight)); // below the strip is the column's
|
|
}
|
|
|
|
// The whole reason the handle exists: two markers that share a frame both stay reachable —
|
|
// markerAtPoint gives its full-height column to the first in draw order, and the handle owns
|
|
// the strip above. Without the split, the loser could never be dragged apart again.
|
|
static void testCoincidentMarkersStayIndependentlyGrabbable() {
|
|
const Rect a = wideArea();
|
|
const std::int64_t markers[2] = {250, 250};
|
|
const int mx = frameToX(overlayOf(a), 1000, 250);
|
|
// The column resolves to the first marker at every height, including the top strip.
|
|
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 2, mx, a.y) == 0);
|
|
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 2, mx, a.bottom() - 1) == 0);
|
|
// The handle, asked first, resolves the second one in that same top strip.
|
|
CHECK(contains(markerHandleRect(overlayOf(a), 1000, 250), mx, a.y));
|
|
CHECK(!contains(markerHandleRect(overlayOf(a), 1000, 250), mx, a.bottom() - 1));
|
|
}
|
|
|
|
static void testMarkerHandleClipsIntoTheArea() {
|
|
const Rect a = wideArea();
|
|
// At the last frame the marker maps to right(); an unclipped tab would claim pixels
|
|
// outside the band the caller already hit-tested.
|
|
const Rect hi = markerHandleRect(overlayOf(a), 1000, 1000);
|
|
CHECK(hi.right() == a.right());
|
|
CHECK(!contains(hi, a.right(), a.y));
|
|
CHECK(contains(hi, a.right() - 1, a.y));
|
|
// And at frame 0 it cannot reach left of the band.
|
|
const Rect lo = markerHandleRect(overlayOf(a), 1000, 0);
|
|
CHECK(lo.x == a.x);
|
|
CHECK(!contains(lo, a.x - 1, a.y));
|
|
}
|
|
|
|
static void testMarkerHandleOnDegenerateAreas() {
|
|
CHECK(markerHandleRect(overlayOf(Rect{}), 1000, 0).empty());
|
|
// A band shorter than the strip yields a handle the height of the band, never taller.
|
|
const Rect thin = Rect{0, 0, 100, 4};
|
|
CHECK(markerHandleRect(overlayOf(thin), 1000, 500).height == 4);
|
|
}
|
|
|
|
// The shell (editor_input_waveform.cpp) resolves a mark's CAP before it iterates the ordinary
|
|
// marker array, because a zero-length fade puts the crossfade cap exactly on the loop-end
|
|
// marker's frame. The same coincidence recurs whenever ANY marker shares that frame, most
|
|
// plausibly the START marker dragged up against the fade edge: this module can't exercise the
|
|
// shell's check-order itself, but it can prove the geometric ambiguity that makes the ordering
|
|
// load-bearing — the array's own first-match rule would otherwise resolve the top strip to the
|
|
// START marker, not the fade handle.
|
|
static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() {
|
|
const Rect a = wideArea();
|
|
const std::int64_t loopEnd = 400, crossfade = 30;
|
|
const std::int64_t fadeEdge = loopEnd - crossfade; // where the crossfade cap sits
|
|
const std::int64_t markers[3] = {fadeEdge, 200, loopEnd}; // start dialled onto the fade edge
|
|
const int mx = frameToX(overlayOf(a), 1000, fadeEdge);
|
|
const int topY = a.y; // inside the handle's top strip
|
|
// Without the shell's priority check, the array's own first-match rule already resolves the
|
|
// column to the start marker (index 0) at this x/y...
|
|
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 3, mx, topY) == 0);
|
|
// ...and the fade handle's rect claims the exact same pixel — the ambiguity the shell
|
|
// resolves by smallest-target-first (the handle's clipped tab is always the narrower
|
|
// target), same as it does for the zero-fade/loop-end case.
|
|
CHECK(contains(markerHandleRect(overlayOf(a), 1000, fadeEdge), mx, topY));
|
|
}
|
|
|
|
// --- The four marks: cap resolve, labels, suppression, crossfade wedge ----------
|
|
|
|
static WaveMarks marksAt(std::int64_t start, std::int64_t loopStart, std::int64_t loopEnd,
|
|
std::int64_t xfade, bool loopPresent) {
|
|
WaveMarks m;
|
|
m.frame[0] = start;
|
|
m.frame[1] = loopStart;
|
|
m.frame[2] = loopEnd;
|
|
m.frame[3] = xfade;
|
|
m.present[0] = true;
|
|
m.present[1] = m.present[2] = m.present[3] = loopPresent;
|
|
return m;
|
|
}
|
|
|
|
static void testEveryMarkAnswersItsOwnCap() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
const WaveMarks m = marksAt(50, 300, 700, 620, true);
|
|
for (int i = 0; i < kWaveMarkCount; ++i) {
|
|
const int mx = frameToX(ov, 1000, m.frame[i]);
|
|
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == i);
|
|
CHECK(capAtPoint(ov, 1000, m, mx, a.y + kMarkerHandleHeight - 1) == i);
|
|
// Below the cap strip is the column's, never the cap's.
|
|
CHECK(capAtPoint(ov, 1000, m, mx, a.y + kMarkerHandleHeight) == -1);
|
|
}
|
|
}
|
|
|
|
static void testAMarkThatIsNotPresentAnswersNoCap() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
const WaveMarks m = marksAt(50, 300, 700, 620, /*loopPresent=*/false);
|
|
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 300), a.y) == -1);
|
|
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 620), a.y) == -1);
|
|
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 50), a.y) == 0); // START stays live
|
|
}
|
|
|
|
// The separability argument the reverse cap order exists for: for a coincident PAIR, one mark
|
|
// answers the cap and the OTHER answers the full-height column, so neither is ever stranded.
|
|
static void testACoincidentPairStaysSeparableAcrossCapAndColumn() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
const int midY = a.y + a.height / 2;
|
|
// Zero-length fade: the crossfade mark sits at loopEnd - 0, i.e. exactly on the END marker.
|
|
// This is the live case — the crossfade is anchored to the seam it closes.
|
|
{
|
|
const WaveMarks m = marksAt(50, 300, 700, 700, true);
|
|
const int mx = frameToX(ov, 1000, 700);
|
|
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast<int>(WaveMark::kCrossfade));
|
|
const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]};
|
|
CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast<int>(WaveMark::kLoopEnd));
|
|
}
|
|
// START dragged onto the loop start: the cap goes to LOOP, the column to START.
|
|
{
|
|
const WaveMarks m = marksAt(300, 300, 700, 100, true);
|
|
const int mx = frameToX(ov, 1000, 300);
|
|
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast<int>(WaveMark::kLoopStart));
|
|
const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]};
|
|
CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast<int>(WaveMark::kStart));
|
|
}
|
|
// START dragged onto the loop end: the cap goes to END, the column to START.
|
|
{
|
|
const WaveMarks m = marksAt(700, 300, 700, 100, true);
|
|
const int mx = frameToX(ov, 1000, 700);
|
|
CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast<int>(WaveMark::kLoopEnd));
|
|
const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]};
|
|
CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast<int>(WaveMark::kStart));
|
|
}
|
|
}
|
|
|
|
// The crossfade is the one mark with NO full-height column, so it must never lose a cap tie.
|
|
static void testTheCrossfadeCapOutranksEveryOtherMark() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
const WaveMarks m = marksAt(400, 400, 400, 400, true); // every mark on one frame
|
|
CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 400), a.y) ==
|
|
static_cast<int>(WaveMark::kCrossfade));
|
|
}
|
|
|
|
static void testLabelSidesKeepEachLabelOutOfTheSpanItBounds() {
|
|
CHECK(!markLabelLeftOfLine(WaveMark::kStart));
|
|
CHECK(!markLabelLeftOfLine(WaveMark::kLoopStart));
|
|
CHECK(markLabelLeftOfLine(WaveMark::kLoopEnd));
|
|
CHECK(markLabelLeftOfLine(WaveMark::kCrossfade));
|
|
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
const int mx = frameToX(ov, 1000, 500);
|
|
const Rect right = markLabelRect(ov, 1000, 500, /*leftOfLine=*/false, 30);
|
|
const Rect left = markLabelRect(ov, 1000, 500, /*leftOfLine=*/true, 30);
|
|
CHECK(right.x == mx + kMarkLabelGap && right.width == 30);
|
|
CHECK(left.right() == mx - kMarkLabelGap && left.width == 30);
|
|
// Directly under the cap strip, so caps and labels never fight for the same pixels.
|
|
CHECK(right.y == a.y + kMarkerHandleHeight && right.height == kMarkLabelHeight);
|
|
CHECK(left.y == right.y);
|
|
}
|
|
|
|
static void testALabelIsNudgedInsideTheAreaRatherThanClipped() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
// At frame 0 a right-side label would still fit; at the last frame it would overhang.
|
|
const Rect atEnd = markLabelRect(ov, 1000, 1000, /*leftOfLine=*/false, 40);
|
|
CHECK(atEnd.width == 40);
|
|
CHECK(atEnd.right() == a.right());
|
|
const Rect atStart = markLabelRect(ov, 1000, 0, /*leftOfLine=*/true, 40);
|
|
CHECK(atStart.width == 40);
|
|
CHECK(atStart.x == a.x);
|
|
// Wider than the whole band, or no band to draw in: nothing placed.
|
|
CHECK(markLabelRect(ov, 1000, 500, false, a.width + 1).empty());
|
|
CHECK(markLabelRect(overlayOf(Rect{0, 0, 200, kMarkerHandleHeight}), 1000, 500, false, 20)
|
|
.empty());
|
|
}
|
|
|
|
static void testOverlappingLabelsAreSuppressedInPlacementOrder() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
// LOOP labels right of its line at 500, XFADE left of its line at 520: the two boxes point
|
|
// at each other and cannot both fit. (LOOP and END never collide however close they get —
|
|
// their labels point away from the span they bound.)
|
|
const WaveMarks m = marksAt(50, 500, 900, 520, true);
|
|
const int w[kWaveMarkCount] = {36, 32, 26, 40};
|
|
const WaveMarkLabels lab = layoutMarkLabels(ov, 1000, m, w, /*promoted=*/-1);
|
|
CHECK(!lab.box[0].empty()); // START, far away, always placed
|
|
CHECK(!lab.box[1].empty()); // LOOP placed before XFADE, so LOOP wins
|
|
CHECK(!lab.box[2].empty()); // END, far away, always placed
|
|
CHECK(lab.box[3].empty()); // XFADE suppressed
|
|
// Every placed box is disjoint from every other.
|
|
for (int i = 0; i < kWaveMarkCount; ++i) {
|
|
for (int j = i + 1; j < kWaveMarkCount; ++j) {
|
|
if (lab.box[i].empty() || lab.box[j].empty()) continue;
|
|
CHECK(lab.box[i].x >= lab.box[j].right() || lab.box[j].x >= lab.box[i].right());
|
|
}
|
|
}
|
|
}
|
|
|
|
// The promoted mark is placed FIRST, so grabbing or hovering a mark always shows its label —
|
|
// even the one the resting layout suppresses.
|
|
static void testThePromotedMarkIsNeverTheSuppressedOne() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
const WaveMarks m = marksAt(50, 500, 900, 520, true);
|
|
const int w[kWaveMarkCount] = {36, 32, 26, 40};
|
|
CHECK(layoutMarkLabels(ov, 1000, m, w, -1).box[3].empty()); // XFADE suppressed at rest
|
|
const WaveMarkLabels grabbed =
|
|
layoutMarkLabels(ov, 1000, m, w, static_cast<int>(WaveMark::kCrossfade));
|
|
CHECK(!grabbed.box[3].empty()); // and placed when it is the one being grabbed
|
|
CHECK(grabbed.box[1].empty()); // LOOP yields to it instead
|
|
}
|
|
|
|
static void testAbsentMarksTakeNoLabel() {
|
|
const Rect a = wideArea();
|
|
const OverlayArea ov = overlayOf(a);
|
|
const WaveMarks m = marksAt(50, 300, 700, 620, /*loopPresent=*/false);
|
|
const int w[kWaveMarkCount] = {36, 32, 26, 40};
|
|
const WaveMarkLabels lab = layoutMarkLabels(ov, 1000, m, w, -1);
|
|
CHECK(!lab.box[0].empty());
|
|
CHECK(lab.box[1].empty() && lab.box[2].empty() && lab.box[3].empty());
|
|
}
|
|
|
|
static void testTheCrossfadeWedgeRampsToItsPeakAtTheSeam() {
|
|
// Zero at the fade's start, the peak at its last column, monotone in between.
|
|
CHECK(crossfadeWedgeHeight(100, 200, 100) == 0);
|
|
CHECK(crossfadeWedgeHeight(100, 200, 199) == kCrossfadeWedgePx);
|
|
int prev = -1;
|
|
for (int x = 100; x < 200; ++x) {
|
|
const int h = crossfadeWedgeHeight(100, 200, x);
|
|
CHECK(h >= prev);
|
|
CHECK(h >= 0 && h <= kCrossfadeWedgePx);
|
|
prev = h;
|
|
}
|
|
// Outside the span it contributes nothing, so a caller can sweep a wider range safely.
|
|
CHECK(crossfadeWedgeHeight(100, 200, 99) == 0);
|
|
CHECK(crossfadeWedgeHeight(100, 200, 200) == 0);
|
|
// Degenerate spans: an empty one draws nothing, a one-column one is all peak.
|
|
CHECK(crossfadeWedgeHeight(100, 100, 100) == 0);
|
|
CHECK(crossfadeWedgeHeight(100, 99, 100) == 0);
|
|
CHECK(crossfadeWedgeHeight(100, 101, 100) == kCrossfadeWedgePx);
|
|
}
|
|
|
|
// --- Per-lane envelope content -------------------------------------------------
|
|
|
|
static void testAsymmetricStereoLanesCarryDifferentContent() {
|
|
// Left is full-scale, right is a tenth of it — the lanes must look materially different.
|
|
const std::size_t frames = 400;
|
|
std::vector<AudioSample> interleaved(frames * 2);
|
|
for (std::size_t f = 0; f < frames; ++f) {
|
|
const AudioSample v = (f % 2 == 0) ? 1.0f : -1.0f;
|
|
interleaved[f * 2 + 0] = v;
|
|
interleaved[f * 2 + 1] = v * 0.1f;
|
|
}
|
|
// ONE pass over the interleaved source, split per lane — what the painter does.
|
|
const reasampler::audio::Envelope env =
|
|
reasampler::audio::computeEnvelope(interleaved, 2, frames, 20);
|
|
const reasampler::audio::Envelope upper = laneEnvelope(env, 0);
|
|
const reasampler::audio::Envelope lower = laneEnvelope(env, 1);
|
|
CHECK(upper.size() == 1 && lower.size() == 1);
|
|
CHECK(upper[0].size() == 20 && lower[0].size() == 20);
|
|
for (std::size_t i = 0; i < 20; ++i) {
|
|
CHECK(upper[0][i].max > 0.9f); // left near full scale
|
|
CHECK(lower[0][i].max < 0.2f); // right an order of magnitude down
|
|
CHECK(!(upper[0][i] == lower[0][i])); // and materially different, bin for bin
|
|
}
|
|
}
|
|
|
|
static void testLaneEnvelopeRejectsOutOfRangeLane() {
|
|
const std::size_t frames = 16;
|
|
std::vector<AudioSample> mono(frames, 0.5f);
|
|
const reasampler::audio::Envelope env =
|
|
reasampler::audio::computeEnvelope(mono, 1, frames, 4);
|
|
CHECK(laneEnvelope(env, 0).size() == 1);
|
|
CHECK(laneEnvelope(env, 1).empty()); // a mono source has no lower lane
|
|
CHECK(laneEnvelope(env, -1).empty());
|
|
}
|
|
|
|
int main() {
|
|
testFrameToXEndpoints();
|
|
testFrameToXClampsOutOfRange();
|
|
testFrameToXDegenerate();
|
|
testXToFrameInverse();
|
|
testXToFrameClampsOutside();
|
|
|
|
testAMarkLandsOnTheWaveformColumnForItsOwnFrame();
|
|
testTheMappingAgreesWithTheDrawChainAtEveryFrame();
|
|
testTheExclusiveSpanEndLandsOnTheRightEdge();
|
|
testXToFrameRoundTripsEveryFrameWhileAFrameOwnsAColumn();
|
|
testColumnsRoundTripWhereFramesShareThem();
|
|
|
|
testMarkerAtPointGrabsWithinBand();
|
|
testMarkerAtPointMissesBetween();
|
|
testMarkerAtPointFirstMatchOnOverlap();
|
|
testMarkerAtPointRejectsNullEmpty();
|
|
|
|
testResolveDragFrameShift();
|
|
testResolveDragFrameClamps();
|
|
testResolveDragFrameTruncatesAtFractionalScale();
|
|
testResolveDragFrameDegenerate();
|
|
testResolveDragFrameLandsOnCursorColumn();
|
|
|
|
testZeroCrossingNearest();
|
|
testZeroCrossingSampleOnZero();
|
|
testZeroCrossingEquidistantTieToLower();
|
|
testZeroCrossingTiesAlwaysResolveToTheLowerFrame();
|
|
testZeroCrossingTakesTheNearerSideFromEitherDirection();
|
|
testZeroCrossingNoneKeepsTarget();
|
|
testZeroCrossingClampsTarget();
|
|
testZeroCrossingDegenerate();
|
|
testSnapRadiusIsThePixelBandsOwnFrameSpan();
|
|
testSnapLeavesASingleCycleMarkWhereItWasDropped();
|
|
testSnapIsUnchangedOnDenseMaterial();
|
|
testSnapTakesACrossingAtTheRadiusAndRefusesOnePastIt();
|
|
testSnapKeepsTheTieRuleInsideTheRadius();
|
|
testSnapAtZeroRadiusMovesNothingButAnExactHit();
|
|
testSnapClampsAndTakesDegenerateInputs();
|
|
|
|
testSurfaceStereoStacksTwoLanes();
|
|
testSurfaceMonoIsOneLane();
|
|
testSurfaceMonoSourceInStereoModeStaysOneLane();
|
|
testSurfaceOverlayIsFullStackedHeightInBothModes();
|
|
testTheOverlayIsExactlyTheDrawnColumnBand();
|
|
testSurfaceDegenerateBandDrawsNothing();
|
|
testSurfaceThinBandRoundsLowerLaneEmpty();
|
|
|
|
testMarkerGrabReachesTheLowerStereoLane();
|
|
testMarkerGrabInMonoSpansTheBand();
|
|
|
|
testMarkerHandleIsATopStripCentredOnTheMarker();
|
|
testCoincidentMarkersStayIndependentlyGrabbable();
|
|
testMarkerHandleClipsIntoTheArea();
|
|
testMarkerHandleOnDegenerateAreas();
|
|
testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge();
|
|
|
|
testEveryMarkAnswersItsOwnCap();
|
|
testAMarkThatIsNotPresentAnswersNoCap();
|
|
testACoincidentPairStaysSeparableAcrossCapAndColumn();
|
|
testTheCrossfadeCapOutranksEveryOtherMark();
|
|
testLabelSidesKeepEachLabelOutOfTheSpanItBounds();
|
|
testALabelIsNudgedInsideTheAreaRatherThanClipped();
|
|
testOverlappingLabelsAreSuppressedInPlacementOrder();
|
|
testThePromotedMarkIsNeverTheSuppressedOne();
|
|
testAbsentMarksTakeNoLabel();
|
|
testTheCrossfadeWedgeRampsToItsPeakAtTheSeam();
|
|
|
|
testAsymmetricStereoLanesCarryDifferentContent();
|
|
testLaneEnvelopeRejectsOutOfRangeLane();
|
|
|
|
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;
|
|
}
|