Ω-W1-T5: one overlay↔waveform mapping, and loop marks that tell the truth about their mode
This commit is contained in:
@@ -192,6 +192,16 @@ static void testWaveformColumnCount() {
|
||||
CHECK(waveformColumnCount(KitBox{0, 0, 0, 40}) == 0);
|
||||
}
|
||||
|
||||
// The instrument's waveform overlay rides this exact column band, and derives its own left
|
||||
// inset by halving what this leaves (waveform_view's waveformOverlayArea) rather than keeping a
|
||||
// second copy of the inset. That halving is only correct while the inset is symmetric, so pin
|
||||
// it: every drawable width loses exactly 4, two per side.
|
||||
static void testTheColumnBandIsInsetSymmetrically() {
|
||||
for (int w = 5; w <= 400; ++w) {
|
||||
CHECK(w - waveformColumnCount(KitBox{0, 0, w, 40}) == 4);
|
||||
}
|
||||
}
|
||||
|
||||
// --- waveform column span ----------------------------------------------------
|
||||
|
||||
// The regression this exists to catch: rounding applied to the resulting y instead of to the
|
||||
@@ -293,6 +303,7 @@ int main() {
|
||||
testListRowHitTestBoundedByCount();
|
||||
testListRowLayoutHitAgreement();
|
||||
testWaveformColumnCount();
|
||||
testTheColumnBandIsInsetSymmetrically();
|
||||
testSymmetricColumnDrawsEqualHeightAboveAndBelowTheZeroLine();
|
||||
testSilentColumnCollapsesOntoTheZeroLine();
|
||||
testTallerAmplitudeNeverDrawsAShorterColumn();
|
||||
|
||||
+189
-17
@@ -3,7 +3,11 @@
|
||||
// (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 (linear map + inverse, edge clamps, degenerate frameCount/width);
|
||||
// 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 (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width
|
||||
@@ -17,6 +21,7 @@
|
||||
|
||||
#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 <cstddef>
|
||||
#include <cstdio>
|
||||
@@ -37,10 +42,13 @@ 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 -> left edge
|
||||
CHECK(frameToX(overlayOf(a), 1000, 1000) == a.right()); // frameCount -> right edge
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -71,14 +79,105 @@ static void testXToFrameClampsOutside() {
|
||||
CHECK(xToFrame(overlayOf(a), 0, a.x + 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::ltrb(0, 0, 800, 60);
|
||||
for (std::int64_t f = 0; f <= 2000; f += 137) {
|
||||
const int x = frameToX(overlayOf(a), 2000, f);
|
||||
const std::int64_t back = xToFrame(overlayOf(a), 2000, x);
|
||||
CHECK(back >= f - 3 && back <= f + 3);
|
||||
// --- 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +289,40 @@ static void testZeroCrossingEquidistantTieToLower() {
|
||||
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};
|
||||
@@ -252,23 +385,54 @@ 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.
|
||||
CHECK(st.overlay.rect == b);
|
||||
// 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, which is also the single lane.
|
||||
CHECK(mo.overlay.rect == b);
|
||||
CHECK(mo.overlay.rect == mo.upper);
|
||||
// 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() {
|
||||
@@ -606,7 +770,12 @@ int main() {
|
||||
testFrameToXDegenerate();
|
||||
testXToFrameInverse();
|
||||
testXToFrameClampsOutside();
|
||||
testFrameToXRoundTrip();
|
||||
|
||||
testAMarkLandsOnTheWaveformColumnForItsOwnFrame();
|
||||
testTheMappingAgreesWithTheDrawChainAtEveryFrame();
|
||||
testTheExclusiveSpanEndLandsOnTheRightEdge();
|
||||
testXToFrameRoundTripsEveryFrameWhileAFrameOwnsAColumn();
|
||||
testColumnsRoundTripWhereFramesShareThem();
|
||||
|
||||
testMarkerAtPointGrabsWithinBand();
|
||||
testMarkerAtPointMissesBetween();
|
||||
@@ -621,6 +790,8 @@ int main() {
|
||||
testZeroCrossingNearest();
|
||||
testZeroCrossingSampleOnZero();
|
||||
testZeroCrossingEquidistantTieToLower();
|
||||
testZeroCrossingTiesAlwaysResolveToTheLowerFrame();
|
||||
testZeroCrossingTakesTheNearerSideFromEitherDirection();
|
||||
testZeroCrossingNoneKeepsTarget();
|
||||
testZeroCrossingClampsTarget();
|
||||
testZeroCrossingDegenerate();
|
||||
@@ -629,6 +800,7 @@ int main() {
|
||||
testSurfaceMonoIsOneLane();
|
||||
testSurfaceMonoSourceInStereoModeStaysOneLane();
|
||||
testSurfaceOverlayIsFullStackedHeightInBothModes();
|
||||
testTheOverlayIsExactlyTheDrawnColumnBand();
|
||||
testSurfaceDegenerateBandDrawsNothing();
|
||||
testSurfaceThinBandRoundsLowerLaneEmpty();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user