Merge Ω-W1-T5: one overlay↔waveform mapping, loop marks honest about their mode
This commit is contained in:
@@ -336,8 +336,8 @@ anything for a trigger shape.
|
|||||||
- `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means.
|
- `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means.
|
||||||
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
||||||
- `waveform_view` — the WAVEFORM band's interior: `resolveLaneSplit` is THE lane-split decision (two lanes only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane), free of any pixel geometry so the meter's bar count can ask the same question without a band rect; `waveformSurface` folds it and then measures it against the band, which is why its `laneCount` can still report 1 for a Stereo split on a band too thin to divide. It also yields **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
|
- `waveform_view` — the WAVEFORM band's interior: `resolveLaneSplit` is THE lane-split decision (two lanes only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane), free of any pixel geometry so the meter's bar count can ask the same question without a band rect; `waveformSurface` folds it and then measures it against the band, which is why its `laneCount` can still report 1 for a Stereo split on a band too thin to divide. It also yields **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
|
||||||
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the band's DRAWN COLUMN SPAN at the band's full height, in both modes: same inset and same width as `component_geometry`'s `waveformColumnCount`, so overlay pixel and waveform column are one pixel and every overlay lands on the column that draws the frame it names. Deriving that width instead of restating the inset is what keeps the two chains from drifting apart again. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
||||||
- **The four marks.** One grammar — line + shaped cap + label — over START / LOOP / END / XFADE. `markerHandleRect` IS the cap: every mark's is the same rect shape, only the glyph inside differs, which is what keeps the claim arbitration seeing one nominal cap area. `capAtPoint` resolves caps in the REVERSE of the column order, so any coincident PAIR stays separable (one answers its cap, the other its column) and the crossfade — the one mark with no column — can never be shadowed. `layoutMarkLabels` places the promoted (grabbed/hovered) mark first and suppresses any box that would overlap one already placed. `crossfadeWedgeHeight` is the ONE ramp both the audible region and the ingredient ghost draw, because they are the same fade weight over the two spans it mixes.
|
- **The four marks.** One grammar — line + shaped cap + label — over START / LOOP / END / XFADE. Which of them are PRESENT is the mode's answer, and drawn iff grabbable: Trigger carries START alone (the loop pair and the fade are absent, not dim), Gate always carries the pair so the drag-to-set-loop gesture survives the enable being off, and the fade needs an active loop to have a seam. `markerHandleRect` IS the cap: every mark's is the same rect shape, only the glyph inside differs, which is what keeps the claim arbitration seeing one nominal cap area. `capAtPoint` resolves caps in the REVERSE of the column order, so any coincident PAIR stays separable (one answers its cap, the other its column) and the crossfade — the one mark with no column — can never be shadowed. `layoutMarkLabels` places the promoted (grabbed/hovered) mark first and suppresses any box that would overlap one already placed. `crossfadeWedgeHeight` is the ONE ramp both the audible region and the ingredient ghost draw, because they are the same fade weight over the two spans it mixes.
|
||||||
- `loop_marks` — the loop enable's state machine, split from the geometry above on the axis the surface already has: that says where a mark is, this says what the loop IS. `SampleLoop::hasLoop` is the single authority and `resolveLoopMarks`/`applyLoopMarks` are its only two folds — the resolve re-parks on `defaultLoopBounds` only when the span is one `resolveLoop` would refuse (so a user's off keeps its positions and `parked` separates the two OFF states), and the write folds collapse-to-off in and ties the crossfade to the SPAN rather than to the enable. Links `loop_span` so the span the user is offered and the span the engine accepts stay one definition.
|
- `loop_marks` — the loop enable's state machine, split from the geometry above on the axis the surface already has: that says where a mark is, this says what the loop IS. `SampleLoop::hasLoop` is the single authority and `resolveLoopMarks`/`applyLoopMarks` are its only two folds — the resolve re-parks on `defaultLoopBounds` only when the span is one `resolveLoop` would refuse (so a user's off keeps its positions and `parked` separates the two OFF states), and the write folds collapse-to-off in and ties the crossfade to the SPAN rather than to the enable. Links `loop_span` so the span the user is offered and the span the engine accepts stay one definition.
|
||||||
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
|
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
|
||||||
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
|
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome)
|
|||||||
|
|
||||||
# sample_bands is PUBLIC since resolveLaneSplit answers in its LaneSplit — the meter's bar
|
# sample_bands is PUBLIC since resolveLaneSplit answers in its LaneSplit — the meter's bar
|
||||||
# count consumes that answer, so the type is part of this module's surface, not an internal.
|
# count consumes that answer, so the type is part of this module's surface, not an internal.
|
||||||
|
# component_geometry is PRIVATE: the overlay area is DERIVED from the draw chain's own column
|
||||||
|
# count so the two cannot drift, but no type of it appears in this module's header.
|
||||||
reasampler_pure_library(waveform_view
|
reasampler_pure_library(waveform_view
|
||||||
SOURCES waveform_view.cpp
|
SOURCES waveform_view.cpp
|
||||||
LINK PUBLIC editor_geometry peaks sample_bands)
|
LINK PUBLIC editor_geometry peaks sample_bands PRIVATE component_geometry)
|
||||||
reasampler_test(waveform_view LINK waveform_view sample_bands)
|
# component_geometry is linked for the test too: proving the overlay rides the drawn column
|
||||||
|
# band takes the real column count, not a copy of it.
|
||||||
|
reasampler_test(waveform_view LINK waveform_view sample_bands component_geometry)
|
||||||
|
|
||||||
# The loop enable's state machine. Links loop_span for the park bounds — the span the user is
|
# The loop enable's state machine. Links loop_span for the park bounds — the span the user is
|
||||||
# offered and the span the engine accepts stay one definition.
|
# offered and the span the engine accepts stay one definition.
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Bo
|
|||||||
SplineGesture gesture, int x, int y);
|
SplineGesture gesture, int x, int y);
|
||||||
|
|
||||||
// The contour's mapping box inside the waveform overlay: the FULL area, so the drawn contour
|
// The contour's mapping box inside the waveform overlay: the FULL area, so the drawn contour
|
||||||
// spans the whole sample width 1:1 with its time axis. No inset — unlike the popup's box, which
|
// spans the whole sample width 1:1 with its time axis. It adds no inset of its own — unlike the
|
||||||
// insets to keep endpoint handles clear of the sheet border, this one must stay 1:1 with the
|
// popup's box, which insets to keep endpoint handles clear of the sheet border — because the
|
||||||
// waveform beneath it. Takes the overlay (not a lane) — see waveform_view.h's overlay contract.
|
// overlay area IS the drawn column span already, which is what makes the 1:1 claim true rather
|
||||||
|
// than off by the waveform's inset. Takes the overlay (not a lane) — see waveform_view.h.
|
||||||
VelocityCurve::Box splineOverlayBox(const OverlayArea& area);
|
VelocityCurve::Box splineOverlayBox(const OverlayArea& area);
|
||||||
|
|
||||||
// Two more rules complete the grammar. Both are enforced in the shell — mouse-tracking / drag
|
// Two more rules complete the grammar. Both are enforced in the shell — mouse-tracking / drag
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdlib> // std::abs (int overload)
|
|
||||||
|
|
||||||
#include "core/instrument/ui/sample_bands.h" // waveformLanes (the band's lane inventory)
|
#include "core/instrument/ui/sample_bands.h" // waveformLanes (the band's lane inventory)
|
||||||
|
#include "core/ui/component_geometry.h" // waveformColumnCount (THE draw chain's columns)
|
||||||
|
|
||||||
namespace reasampler::instrument::ui {
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
@@ -21,7 +21,14 @@ std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
OverlayArea waveformOverlayArea(const Rect& band) {
|
OverlayArea waveformOverlayArea(const Rect& band) {
|
||||||
return OverlayArea{band.empty() ? Rect{} : band};
|
if (band.empty()) return OverlayArea{};
|
||||||
|
// The overlay IS the drawn column band, so an overlay pixel and a waveform column are the
|
||||||
|
// same pixel. Both the width and the inset are DERIVED from the draw chain's own column
|
||||||
|
// count rather than restated here — a second copy of the inset is how the two drifted
|
||||||
|
// apart, and test_component_geometry pins the symmetry this halving rests on.
|
||||||
|
const int columns = ::reasampler::ui::waveformColumnCount(band);
|
||||||
|
if (columns <= 0) return OverlayArea{};
|
||||||
|
return OverlayArea{Rect{band.x + (band.width - columns) / 2, band.y, columns, band.height}};
|
||||||
}
|
}
|
||||||
|
|
||||||
LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels) {
|
LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels) {
|
||||||
@@ -52,9 +59,12 @@ int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t fram
|
|||||||
const int w = std::max(0, area.rect.width);
|
const int w = std::max(0, area.rect.width);
|
||||||
if (frameCount <= 0 || w <= 0) return area.rect.x;
|
if (frameCount <= 0 || w <= 0) return area.rect.x;
|
||||||
const std::int64_t f = clampFrame(frame, frameCount);
|
const std::int64_t f = clampFrame(frame, frameCount);
|
||||||
// x = left + round(f * w / frameCount); multiply before divide to keep this exact.
|
// The exact inverse of the partition below: the column holding frame f is
|
||||||
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
|
// ceil((f+1)*w/N) - 1, written as one truncating divide. min(w) covers only the closed
|
||||||
return area.rect.x + static_cast<int>(num / frameCount);
|
// domain's top end — f == frameCount is a span's EXCLUSIVE edge, not a frame, and lands on
|
||||||
|
// the band's right edge. (f+1)*w stays inside int64 for any drawable band.
|
||||||
|
const std::int64_t col = ((f + 1) * static_cast<std::int64_t>(w) - 1) / frameCount;
|
||||||
|
return area.rect.x + static_cast<int>(std::min<std::int64_t>(col, w));
|
||||||
}
|
}
|
||||||
|
|
||||||
std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x) {
|
std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x) {
|
||||||
@@ -63,10 +73,11 @@ std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x) {
|
|||||||
if (frameCount <= 0 || w <= 0) return 0;
|
if (frameCount <= 0 || w <= 0) return 0;
|
||||||
if (x <= r.x) return 0;
|
if (x <= r.x) return 0;
|
||||||
if (x >= r.right()) return frameCount;
|
if (x >= r.right()) return frameCount;
|
||||||
const std::int64_t dx = static_cast<std::int64_t>(x - r.x);
|
// THE partition, in the same integer form peaks' computeEnvelope/columnMinMax bin with:
|
||||||
// Inverse of frameToX: frame = round(dx * frameCount / w).
|
// column c owns frames [c*N/w, (c+1)*N/w). This answers a column's FIRST frame, so it is
|
||||||
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
|
// the frame that column actually draws.
|
||||||
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
|
const std::int64_t col = static_cast<std::int64_t>(x - r.x);
|
||||||
|
return clampFrame(col * frameCount / static_cast<std::int64_t>(w), frameCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame) {
|
Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame) {
|
||||||
@@ -158,13 +169,13 @@ std::int64_t resolveDragFrame(const OverlayArea& area, std::int64_t frameCount,
|
|||||||
if (dxPixels == 0) return start;
|
if (dxPixels == 0) return start;
|
||||||
const int w = std::max(0, area.rect.width);
|
const int w = std::max(0, area.rect.width);
|
||||||
if (frameCount <= 0 || w <= 0) return start; // no room to move
|
if (frameCount <= 0 || w <= 0) return start; // no room to move
|
||||||
// Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame).
|
// THE unified mapping, not a second one: find the start frame's own column (frameToX),
|
||||||
const std::int64_t magnitude =
|
// walk it by dxPixels, and read the frame back off the resulting pixel (xToFrame) — so a
|
||||||
(static_cast<std::int64_t>(std::abs(dxPixels)) * frameCount +
|
// drag always resolves to the column under the cursor, never a proportional approximation
|
||||||
static_cast<std::int64_t>(w) / 2) /
|
// of it. A prior independent linear map here could disagree with frameToX/xToFrame's
|
||||||
static_cast<std::int64_t>(w);
|
// truncating column partition, most visibly when frames < columns.
|
||||||
const std::int64_t shift = dxPixels > 0 ? magnitude : -magnitude;
|
const int grabX = frameToX(area, frameCount, start);
|
||||||
return clampFrame(start + shift, frameCount);
|
return xToFrame(area, frameCount, grabX + dxPixels);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
||||||
|
|||||||
@@ -21,15 +21,15 @@ using audio::AudioSample;
|
|||||||
// What the waveform band actually draws: the channel lane(s), and THE rect every overlay
|
// What the waveform band actually draws: the channel lane(s), and THE rect every overlay
|
||||||
// riding the waveform occupies.
|
// riding the waveform occupies.
|
||||||
//
|
//
|
||||||
// OVERLAY CONTRACT — `overlay` is the whole band in BOTH modes, never a lane. The amp
|
// OVERLAY CONTRACT — `overlay` spans the whole band's HEIGHT in BOTH modes, never a lane. The
|
||||||
// envelope trace and its node handles, the start/loop markers, and the loop region draw
|
// amp envelope trace and its node handles, the start/loop markers, and the loop region draw
|
||||||
// ONCE into `overlay`, spanning both stacked lanes in stereo. Hit-testing reads the same
|
// ONCE into `overlay`, spanning both stacked lanes in stereo. Hit-testing reads the same
|
||||||
// rect, so a grab in the lower lane resolves to the same overlay item as one in the upper.
|
// rect, so a grab in the lower lane resolves to the same overlay item as one in the upper.
|
||||||
// Anything that draws per lane is a duplicate and a defect.
|
// Anything that draws per lane is a duplicate and a defect.
|
||||||
struct WaveformSurface {
|
struct WaveformSurface {
|
||||||
Rect upper; // lane 0 -> channel 0 (LEFT); the whole band when single-lane
|
Rect upper; // lane 0 -> channel 0 (LEFT); the whole band when single-lane
|
||||||
Rect lower; // lane 1 -> channel 1 (RIGHT); empty() when single-lane
|
Rect lower; // lane 1 -> channel 1 (RIGHT); empty() when single-lane
|
||||||
OverlayArea overlay; // the full band, both modes
|
OverlayArea overlay; // the band's drawn column span, full height, both modes
|
||||||
int laneCount = 0; // 0 on a degenerate band, else 1 or 2 — matches `lower`'s emptiness
|
int laneCount = 0; // 0 on a degenerate band, else 1 or 2 — matches `lower`'s emptiness
|
||||||
// (2 iff lower non-empty). For a non-empty band <= 2px tall, `upper`
|
// (2 iff lower non-empty). For a non-empty band <= 2px tall, `upper`
|
||||||
// can be empty too while this still reports 1 — unreachable through
|
// can be empty too while this still reports 1 — unreachable through
|
||||||
@@ -51,6 +51,11 @@ WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceCha
|
|||||||
// THE overlay area, standalone — same value as WaveformSurface::overlay, for the hit-test
|
// THE overlay area, standalone — same value as WaveformSurface::overlay, for the hit-test
|
||||||
// paths that have no channel count to hand. An overlay's rect never depends on the lane
|
// paths that have no channel count to hand. An overlay's rect never depends on the lane
|
||||||
// split, which is exactly the contract.
|
// split, which is exactly the contract.
|
||||||
|
//
|
||||||
|
// It is the band's DRAWN COLUMN SPAN, not the band: same inset, same width as
|
||||||
|
// component_geometry's waveformColumnCount, so overlay pixel i and waveform column i are one
|
||||||
|
// pixel. Full band HEIGHT still — the inset is horizontal only. Empty when the band is too
|
||||||
|
// narrow to hold a column.
|
||||||
OverlayArea waveformOverlayArea(const Rect& band);
|
OverlayArea waveformOverlayArea(const Rect& band);
|
||||||
|
|
||||||
// The single-channel envelope lane `lane` draws, taken from a multi-channel envelope
|
// The single-channel envelope lane `lane` draws, taken from a multi-channel envelope
|
||||||
@@ -64,13 +69,37 @@ audio::Envelope laneEnvelope(const audio::Envelope& env, int lane);
|
|||||||
// edge-grab idiom.
|
// edge-grab idiom.
|
||||||
inline constexpr int kMarkerGrabWidth = 5;
|
inline constexpr int kMarkerGrabWidth = 5;
|
||||||
|
|
||||||
// x pixel of `frame` under the linear map: frame 0 -> area.x, frame frameCount -> area.right().
|
// --- THE frame<->pixel mapping ---------------------------------------------------------------
|
||||||
// Frame is clamped to [0, frameCount] before mapping. frameCount <= 0 or a zero-width area pins
|
//
|
||||||
// every frame to area.x. Takes the overlay (not a lane) — see the OVERLAY CONTRACT above.
|
// ONE map, shared with the waveform draw: column c owns frames [c*N/w, (c+1)*N/w), which is
|
||||||
|
// peaks' own bin partition, over an area that is exactly the drawn column span. So a mark, the
|
||||||
|
// loop fill, a wedge and the contour all land on the column that draws the frame they name.
|
||||||
|
// A SECOND frame->pixel map anywhere is the defect this pair exists to prevent.
|
||||||
|
//
|
||||||
|
// SAMPLE-ACCURATE WHEN frames < columns: one frame then spans several columns, and frameToX
|
||||||
|
// answers the LAST of them — that end is what the exact inverse of the partition above yields
|
||||||
|
// in BOTH regimes, so it needs one formula rather than a regime branch. The mark is inside its
|
||||||
|
// own frame's span either way, and xToFrame round-trips it exactly (xToFrame(frameToX(f)) == f
|
||||||
|
// for every f whenever frames <= columns, i.e. exactly where the choice of end is observable).
|
||||||
|
// Above that, several frames share a column and the round trip snaps to the column's first
|
||||||
|
// frame, which is the quantization the shared column already is.
|
||||||
|
//
|
||||||
|
// KNOWN CONVENTION MISMATCH, not a bug: in the frames < columns regime a mark lands on the
|
||||||
|
// LAST column of its frame's span (above), while the spline contour and the staged AHD
|
||||||
|
// polyline map normalized t in [0,1] straight across the same box and so land on a span's
|
||||||
|
// FIRST column instead. Both land inside the frame's own span, so the mapping contract holds,
|
||||||
|
// but the two conventions can disagree by up to a span's width for captures under ~1000
|
||||||
|
// frames. Unifying them is a real option, left alone here since it touches the spline/AHD draw
|
||||||
|
// path rather than this pair.
|
||||||
|
|
||||||
|
// x pixel of `frame`: the column that draws it. `frameCount` itself is a span's EXCLUSIVE end,
|
||||||
|
// not a frame, and maps to area.right(). Frame is clamped to [0, frameCount] first;
|
||||||
|
// frameCount <= 0 or a zero-width area pins every frame to area.x. Takes the overlay (not a
|
||||||
|
// lane) — see the OVERLAY CONTRACT above.
|
||||||
int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame);
|
int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame);
|
||||||
|
|
||||||
// Inverse of frameToX: the frame a point x maps to, clamped to [0, frameCount]. A point left of
|
// The FIRST frame the column at `x` draws — the partition's own begin, so this is the frame
|
||||||
// area.x yields 0; right of area.right() yields frameCount.
|
// that column shows. A point left of area.x yields 0; right of area.right() yields frameCount.
|
||||||
std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x);
|
std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x);
|
||||||
|
|
||||||
// A marker's grab HANDLE — THE CAP, in the mark grammar's vocabulary: a tab riding the top of
|
// A marker's grab HANDLE — THE CAP, in the mark grammar's vocabulary: a tab riding the top of
|
||||||
@@ -148,9 +177,11 @@ int crossfadeWedgeHeight(int x0, int x1, int x);
|
|||||||
int markerAtPoint(const OverlayArea& area, std::int64_t frameCount, const std::int64_t* frames,
|
int markerAtPoint(const OverlayArea& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||||
int count, int x, int y);
|
int count, int x, int y);
|
||||||
|
|
||||||
// Resolves a drag to a new frame: `startFrame` shifted by round(dxPixels * frameCount /
|
// Resolves a drag to a new frame: locates `startFrame`'s own column via frameToX, walks it by
|
||||||
// areaWidth), clamped to [0, frameCount]. The shell applies between-marker clamps (e.g.
|
// dxPixels, and reads the frame back via xToFrame — the SAME partition, so the result is always
|
||||||
// start <= loopEnd) after this per-marker resolve.
|
// the frame the cursor's column actually draws, never a proportional approximation of it.
|
||||||
|
// Clamped to [0, frameCount]. The shell applies between-marker clamps (e.g. start <= loopEnd)
|
||||||
|
// after this per-marker resolve.
|
||||||
std::int64_t resolveDragFrame(const OverlayArea& area, std::int64_t frameCount,
|
std::int64_t resolveDragFrame(const OverlayArea& area, std::int64_t frameCount,
|
||||||
std::int64_t startFrame, int dxPixels);
|
std::int64_t startFrame, int dxPixels);
|
||||||
|
|
||||||
|
|||||||
@@ -150,24 +150,26 @@ instrument::ui::WaveMarks ReaSamplerEditor::waveMarksFor(const SetupMarkers& m)
|
|||||||
w.frame[static_cast<int>(WaveMark::kLoopEnd)] = m.loopEnd;
|
w.frame[static_cast<int>(WaveMark::kLoopEnd)] = m.loopEnd;
|
||||||
// The crossfade grows LEFT from the seam it closes, which is where it is audible.
|
// The crossfade grows LEFT from the seam it closes, which is where it is audible.
|
||||||
w.frame[static_cast<int>(WaveMark::kCrossfade)] = m.loopEnd - m.crossfade;
|
w.frame[static_cast<int>(WaveMark::kCrossfade)] = m.loopEnd - m.crossfade;
|
||||||
|
// Trigger has no loop at all, so the pair and the fade are ABSENT rather than shown in an
|
||||||
|
// off state — a mark whose gesture the mode does not offer was read as broken, not as off.
|
||||||
|
// Gate keeps the pair whatever the enable says: that is the drag-to-set-loop affordance.
|
||||||
// The crossfade mark belongs to an ACTIVE loop: with the enable off there is no seam for it
|
// The crossfade mark belongs to an ACTIVE loop: with the enable off there is no seam for it
|
||||||
// to sit on, and no length to drag.
|
// to sit on, and no length to drag.
|
||||||
|
const bool gate = loopControlsLive();
|
||||||
w.present[static_cast<int>(WaveMark::kStart)] = true;
|
w.present[static_cast<int>(WaveMark::kStart)] = true;
|
||||||
w.present[static_cast<int>(WaveMark::kLoopStart)] = true;
|
w.present[static_cast<int>(WaveMark::kLoopStart)] = gate;
|
||||||
w.present[static_cast<int>(WaveMark::kLoopEnd)] = true;
|
w.present[static_cast<int>(WaveMark::kLoopEnd)] = gate;
|
||||||
w.present[static_cast<int>(WaveMark::kCrossfade)] = m.hasLoop;
|
w.present[static_cast<int>(WaveMark::kCrossfade)] = gate && m.hasLoop;
|
||||||
return w;
|
return w;
|
||||||
}
|
}
|
||||||
|
|
||||||
instrument::ui::WaveMarks ReaSamplerEditor::grabbableMarks(const SetupMarkers& m) const {
|
instrument::ui::WaveMarks ReaSamplerEditor::grabbableMarks(const SetupMarkers& m) const {
|
||||||
using instrument::ui::WaveMark;
|
// Drawn IFF grabbable is the product rule, so this is an exact alias of waveMarksFor and
|
||||||
instrument::ui::WaveMarks w = waveMarksFor(m);
|
// cannot currently diverge from it. Kept as its own seam anyway because paint and hit-test
|
||||||
if (!loopControlsLive()) {
|
// are separate questions in principle — but do NOT re-add a suppression here: the
|
||||||
w.present[static_cast<int>(WaveMark::kLoopStart)] = false;
|
// Gate-with-loop-off marks are drawn grey precisely so they can still be dragged, and
|
||||||
w.present[static_cast<int>(WaveMark::kLoopEnd)] = false;
|
// dragging one is what turns the enable on.
|
||||||
w.present[static_cast<int>(WaveMark::kCrossfade)] = false;
|
return waveMarksFor(m);
|
||||||
}
|
|
||||||
return w;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::setLoopEnabled(bool on) {
|
void ReaSamplerEditor::setLoopEnabled(bool on) {
|
||||||
|
|||||||
@@ -44,13 +44,18 @@ namespace {
|
|||||||
// cap, never a curve.
|
// cap, never a curve.
|
||||||
constexpr Role kRoleStartMarker = Role::OverlayTrace;
|
constexpr Role kRoleStartMarker = Role::OverlayTrace;
|
||||||
constexpr Role kRoleLoopMarker = Role::AccentSecondary;
|
constexpr Role kRoleLoopMarker = Role::AccentSecondary;
|
||||||
|
// A loop mark whose enable is off keeps its position and its cap — and its full weight, since
|
||||||
|
// it is still draggable. It changes HUE, not opacity: the dim teal it replaces read as broken
|
||||||
|
// rather than as off. Grey against the lime deliberately sits under the 3:1 state-indicator
|
||||||
|
// floor the two-neighbour rule (core/ui/CLAUDE.md) sets — on a mark that stays LIVE and still
|
||||||
|
// drives the loop enable, so this is NOT the WCAG 1.4.11 inactive-component carve-out. It is a
|
||||||
|
// deliberate trade (the off cue reads as lower contrast on a control that can still be
|
||||||
|
// grabbed), pending Daniel's eye in the DAW. Do not change the colour to chase it.
|
||||||
|
constexpr Role kRoleLoopMarkerOff = Role::TextDim;
|
||||||
|
|
||||||
// Mark weights. A Disabled mark (loop off, or Trigger) keeps its position and its cap so the
|
// Mark weights. The crossfade is a SOFT boundary and rides below the loop pair's weight at rest.
|
||||||
// information survives the state; the crossfade is a SOFT boundary and rides below the loop
|
|
||||||
// pair's weight at rest.
|
|
||||||
constexpr float kMarkAlpha = 1.0f;
|
constexpr float kMarkAlpha = 1.0f;
|
||||||
constexpr float kMarkAlphaXfade = 0.7f;
|
constexpr float kMarkAlphaXfade = 0.7f;
|
||||||
constexpr float kMarkAlphaDisabled = 0.4f;
|
|
||||||
|
|
||||||
// The dashed crossfade line: a 3 px stroke every 6 px down the band.
|
// The dashed crossfade line: a 3 px stroke every 6 px down the band.
|
||||||
constexpr int kDashOn = 3;
|
constexpr int kDashOn = 3;
|
||||||
@@ -217,9 +222,11 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band,
|
|||||||
// stereo view reads one loop region rather than two.
|
// stereo view reads one loop region rather than two.
|
||||||
const OverlayArea& overlay = surface.overlay;
|
const OverlayArea& overlay = surface.overlay;
|
||||||
const Rect& overlayRect = overlay.rect;
|
const Rect& overlayRect = overlay.rect;
|
||||||
const bool loopLive = loopControlsLive();
|
|
||||||
const bool loopOn = m.hasLoop && loopLive;
|
|
||||||
const WaveMarks marks = waveMarksFor(m);
|
const WaveMarks marks = waveMarksFor(m);
|
||||||
|
// The mark set is the single source for whether this face has a loop to say anything about
|
||||||
|
// — in Trigger the pair is absent, so the span, the wedges and the caption go with it.
|
||||||
|
const bool loopShown = marks.present[static_cast<int>(WaveMark::kLoopStart)];
|
||||||
|
const bool loopOn = m.hasLoop && loopShown;
|
||||||
const LICE_pixel loopInk = toLice(roleColor(kRoleLoopMarker));
|
const LICE_pixel loopInk = toLice(roleColor(kRoleLoopMarker));
|
||||||
|
|
||||||
const int lx = frameToX(overlay, frames, m.loopStart);
|
const int lx = frameToX(overlay, frames, m.loopStart);
|
||||||
@@ -249,10 +256,10 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The state caption, centred in the span: the two OFF states say different things because
|
// The state caption, centred in the span: the two OFF states say different things because
|
||||||
// they mean different things, and Trigger's refusal names its own reason.
|
// they mean different things, and the grey marks alone cannot say WHICH off state this is
|
||||||
const char* caption = nullptr;
|
// or that dragging will fix it. Trigger has no span to centre anything in.
|
||||||
if (!loopLive) caption = "LOOP \xe2\x80\x94 GATE ONLY"; // "LOOP — GATE ONLY" (em dash, UTF-8)
|
const char* caption =
|
||||||
else if (!m.hasLoop) caption = m.parked ? "DRAG TO SET LOOP" : "LOOP OFF";
|
(loopShown && !m.hasLoop) ? (m.parked ? "DRAG TO SET LOOP" : "LOOP OFF") : nullptr;
|
||||||
if (caption != nullptr && rx > lx) {
|
if (caption != nullptr && rx > lx) {
|
||||||
// Tight box (kMarkLabelHeight, not the whole overlay) centered on the same midline the
|
// Tight box (kMarkLabelHeight, not the whole overlay) centered on the same midline the
|
||||||
// full-height rect already centered DT_VCENTER text on, so the scrim darkens only the
|
// full-height rect already centered DT_VCENTER text on, so the scrim darkens only the
|
||||||
@@ -280,16 +287,16 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band,
|
|||||||
Role::TextDim);
|
Role::TextDim);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Line + shaped cap per mark, one grammar. A mark whose gesture is refused draws Disabled
|
// Line + shaped cap per mark, one grammar. A loop mark with the enable off draws grey but
|
||||||
// rather than hidden — the position is information the user put there.
|
// full weight — the position is information the user put there, and it is still draggable.
|
||||||
for (int i = 0; i < kWaveMarkCount; ++i) {
|
for (int i = 0; i < kWaveMarkCount; ++i) {
|
||||||
if (!marks.present[i]) continue;
|
if (!marks.present[i]) continue;
|
||||||
const WaveMark which = static_cast<WaveMark>(i);
|
const WaveMark which = static_cast<WaveMark>(i);
|
||||||
const bool isStart = (which == WaveMark::kStart);
|
const bool isStart = (which == WaveMark::kStart);
|
||||||
const bool dim = !isStart && !loopOn;
|
const bool off = !isStart && !loopOn;
|
||||||
const LICE_pixel ink = isStart ? toLice(roleColor(kRoleStartMarker)) : loopInk;
|
const LICE_pixel ink = isStart ? toLice(roleColor(kRoleStartMarker))
|
||||||
const float alpha = dim ? kMarkAlphaDisabled
|
: (off ? toLice(roleColor(kRoleLoopMarkerOff)) : loopInk);
|
||||||
: (which == WaveMark::kCrossfade ? kMarkAlphaXfade : kMarkAlpha);
|
const float alpha = (which == WaveMark::kCrossfade) ? kMarkAlphaXfade : kMarkAlpha;
|
||||||
const int mx = frameToX(overlay, frames, marks.frame[i]);
|
const int mx = frameToX(overlay, frames, marks.frame[i]);
|
||||||
if (which == WaveMark::kCrossfade) {
|
if (which == WaveMark::kCrossfade) {
|
||||||
// Dashed: a soft boundary, not a hard one.
|
// Dashed: a soft boundary, not a hard one.
|
||||||
|
|||||||
@@ -192,6 +192,16 @@ static void testWaveformColumnCount() {
|
|||||||
CHECK(waveformColumnCount(KitBox{0, 0, 0, 40}) == 0);
|
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 ----------------------------------------------------
|
// --- waveform column span ----------------------------------------------------
|
||||||
|
|
||||||
// The regression this exists to catch: rounding applied to the resulting y instead of to the
|
// The regression this exists to catch: rounding applied to the resulting y instead of to the
|
||||||
@@ -293,6 +303,7 @@ int main() {
|
|||||||
testListRowHitTestBoundedByCount();
|
testListRowHitTestBoundedByCount();
|
||||||
testListRowLayoutHitAgreement();
|
testListRowLayoutHitAgreement();
|
||||||
testWaveformColumnCount();
|
testWaveformColumnCount();
|
||||||
|
testTheColumnBandIsInsetSymmetrically();
|
||||||
testSymmetricColumnDrawsEqualHeightAboveAndBelowTheZeroLine();
|
testSymmetricColumnDrawsEqualHeightAboveAndBelowTheZeroLine();
|
||||||
testSilentColumnCollapsesOntoTheZeroLine();
|
testSilentColumnCollapsesOntoTheZeroLine();
|
||||||
testTallerAmplitudeNeverDrawsAShorterColumn();
|
testTallerAmplitudeNeverDrawsAShorterColumn();
|
||||||
|
|||||||
+233
-27
@@ -3,12 +3,17 @@
|
|||||||
// (lane split + the full-height overlay contract) and its frame<->pixel mapping, marker grab
|
// (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.
|
// 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);
|
// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection);
|
||||||
// markerHandleRect (the top-strip tab that keeps coincident markers independently grabbable);
|
// markerHandleRect (the top-strip tab that keeps coincident markers independently grabbable);
|
||||||
// resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width
|
// resolveDragFrame (drag lands on the frameToX/xToFrame column under the cursor, clamp to
|
||||||
// no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower,
|
// [0,frameCount], zero-delta/zero-width no-ops); nearestZeroCrossing (nearest sign-change,
|
||||||
// no-crossing keeps target, target clamp, degenerate buffers); the four marks (per-mark cap
|
// sample-on-zero, equidistant-tie-to-lower, no-crossing keeps target, target clamp, degenerate
|
||||||
|
// buffers); the four marks (per-mark cap
|
||||||
// resolve, the reverse cap order that keeps a coincident pair separable, label sides/nudging,
|
// 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);
|
// the suppression rule and its promoted-first placement, the crossfade wedge ramp);
|
||||||
// waveformSurface (two stacked
|
// waveformSurface (two stacked
|
||||||
@@ -17,6 +22,7 @@
|
|||||||
|
|
||||||
#include "../src/core/instrument/ui/waveform_view.h"
|
#include "../src/core/instrument/ui/waveform_view.h"
|
||||||
#include "../src/core/instrument/ui/sample_bands.h" // kWaveformMinHeight, kLaneGap
|
#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 <cstddef>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -37,10 +43,13 @@ static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 90); } // width 1000
|
|||||||
|
|
||||||
// --- frameToX / xToFrame ------------------------------------------------------
|
// --- 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() {
|
static void testFrameToXEndpoints() {
|
||||||
const Rect a = wideArea();
|
const Rect a = wideArea();
|
||||||
CHECK(frameToX(overlayOf(a), 1000, 0) == a.x); // frame 0 -> left edge
|
CHECK(frameToX(overlayOf(a), 1000, 0) == a.x); // frame 0 -> first column
|
||||||
CHECK(frameToX(overlayOf(a), 1000, 1000) == a.right()); // frameCount -> right edge
|
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)
|
CHECK(frameToX(overlayOf(a), 1000, 500) == a.x + 500); // midpoint (1:1 here)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +80,105 @@ static void testXToFrameClampsOutside() {
|
|||||||
CHECK(xToFrame(overlayOf(a), 0, a.x + 10) == 0); // no frames -> 0
|
CHECK(xToFrame(overlayOf(a), 0, a.x + 10) == 0); // no frames -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testFrameToXRoundTrip() {
|
// --- The frame<->pixel mapping against the draw chain it must agree with -------
|
||||||
// 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).
|
// The whole Ω.6 contract: the overlay reads the SAME frame->column partition the waveform is
|
||||||
const Rect a = Rect::ltrb(0, 0, 800, 60);
|
// binned and drawn through, so these fixtures run the REAL chain (computeEnvelope +
|
||||||
for (std::int64_t f = 0; f <= 2000; f += 137) {
|
// columnMinMax) rather than restating the partition, which would only prove the test agrees
|
||||||
const int x = frameToX(overlayOf(a), 2000, f);
|
// with itself.
|
||||||
const std::int64_t back = xToFrame(overlayOf(a), 2000, x);
|
|
||||||
CHECK(back >= f - 3 && back <= f + 3);
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,13 +244,13 @@ static void testResolveDragFrameClamps() {
|
|||||||
CHECK(resolveDragFrame(ov, 1000, 950, 500) == 1000); // clamp high (== frameCount)
|
CHECK(resolveDragFrame(ov, 1000, 950, 500) == 1000); // clamp high (== frameCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testResolveDragFrameRounds() {
|
static void testResolveDragFrameTruncatesAtFractionalScale() {
|
||||||
// 500px area over 1000 frames -> 2 frames/px. A +3px drag -> round(6.0)=6; the rounding is
|
// 300px area over 1000 frames -> 3.33 frames/px, so frameToX(start) -> +dx -> xToFrame
|
||||||
// at the frame centre. Use a scale where a fractional result appears.
|
// lands on the column's truncating partition rather than a whole multiple of dx.
|
||||||
const OverlayArea ov = overlayOf(Rect::ltrb(0, 0, 300, 60)); // 1000 frames / 300px = 3.33 frames/px
|
const OverlayArea ov = overlayOf(Rect::ltrb(0, 0, 300, 60));
|
||||||
// +3px -> 3*1000/300 = 10.0 -> 10 frames.
|
// frameToX(100) = 30; xToFrame(30 + 3) = 33*1000/300 = 110.0 -> 110 frames.
|
||||||
CHECK(resolveDragFrame(ov, 1000, 100, 3) == 110);
|
CHECK(resolveDragFrame(ov, 1000, 100, 3) == 110);
|
||||||
// +1px -> 1000/300 = 3.33 -> rounds to 3.
|
// frameToX(100) = 30; xToFrame(30 + 1) = 31*1000/300 = 103.33 -> truncates to 103.
|
||||||
CHECK(resolveDragFrame(ov, 1000, 100, 1) == 103);
|
CHECK(resolveDragFrame(ov, 1000, 100, 1) == 103);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +264,38 @@ static void testResolveDragFrameDegenerate() {
|
|||||||
CHECK(resolveDragFrame(ov, 1000, 5000, 0) == 1000);
|
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 ------------------------------------------------------
|
// --- nearestZeroCrossing ------------------------------------------------------
|
||||||
|
|
||||||
static void testZeroCrossingNearest() {
|
static void testZeroCrossingNearest() {
|
||||||
@@ -190,6 +322,40 @@ static void testZeroCrossingEquidistantTieToLower() {
|
|||||||
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 2);
|
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() {
|
static void testZeroCrossingNoneKeepsTarget() {
|
||||||
// All one sign -> no crossing -> the (clamped) target comes back unchanged.
|
// All one sign -> no crossing -> the (clamped) target comes back unchanged.
|
||||||
std::vector<AudioSample> pcm = {0.5f, 0.6f, 0.7f, 0.8f};
|
std::vector<AudioSample> pcm = {0.5f, 0.6f, 0.7f, 0.8f};
|
||||||
@@ -252,23 +418,54 @@ static void testSurfaceOverlayIsFullStackedHeightInBothModes() {
|
|||||||
const Rect b = band();
|
const Rect b = band();
|
||||||
const WaveformSurface st = waveformSurface(b, /*stereoMode=*/true, 2);
|
const WaveformSurface st = waveformSurface(b, /*stereoMode=*/true, 2);
|
||||||
const WaveformSurface mo = waveformSurface(b, /*stereoMode=*/false, 2);
|
const WaveformSurface mo = waveformSurface(b, /*stereoMode=*/false, 2);
|
||||||
// Stereo: ONE overlay rect spanning both lanes, not either lane.
|
// Stereo: ONE overlay rect spanning both lanes, not either lane. The HEIGHT is what the
|
||||||
CHECK(st.overlay.rect == b);
|
// 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.height == st.upper.height + kLaneGap + st.lower.height);
|
||||||
CHECK(st.overlay.rect != st.upper && st.overlay.rect != st.lower);
|
CHECK(st.overlay.rect != st.upper && st.overlay.rect != st.lower);
|
||||||
// Mono: the same rect, which is also the single lane.
|
// Mono: the same rect. It is NOT the single lane any more — the lane is the whole band,
|
||||||
CHECK(mo.overlay.rect == b);
|
// the overlay is the band's drawn column span inside it.
|
||||||
CHECK(mo.overlay.rect == mo.upper);
|
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.
|
// The standalone accessor the hit-test paths use agrees with the resolved surface.
|
||||||
CHECK(waveformOverlayArea(b) == st.overlay);
|
CHECK(waveformOverlayArea(b) == st.overlay);
|
||||||
CHECK(waveformOverlayArea(b) == mo.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() {
|
static void testSurfaceDegenerateBandDrawsNothing() {
|
||||||
const WaveformSurface s = waveformSurface(Rect{10, 10, 0, 0}, true, 2);
|
const WaveformSurface s = waveformSurface(Rect{10, 10, 0, 0}, true, 2);
|
||||||
CHECK(s.laneCount == 0);
|
CHECK(s.laneCount == 0);
|
||||||
CHECK(s.upper.empty() && s.lower.empty() && s.overlay.rect.empty());
|
CHECK(s.upper.empty() && s.lower.empty() && s.overlay.rect.empty());
|
||||||
CHECK(waveformOverlayArea(Rect{10, 10, 0, 0}).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() {
|
static void testSurfaceThinBandRoundsLowerLaneEmpty() {
|
||||||
@@ -606,7 +803,12 @@ int main() {
|
|||||||
testFrameToXDegenerate();
|
testFrameToXDegenerate();
|
||||||
testXToFrameInverse();
|
testXToFrameInverse();
|
||||||
testXToFrameClampsOutside();
|
testXToFrameClampsOutside();
|
||||||
testFrameToXRoundTrip();
|
|
||||||
|
testAMarkLandsOnTheWaveformColumnForItsOwnFrame();
|
||||||
|
testTheMappingAgreesWithTheDrawChainAtEveryFrame();
|
||||||
|
testTheExclusiveSpanEndLandsOnTheRightEdge();
|
||||||
|
testXToFrameRoundTripsEveryFrameWhileAFrameOwnsAColumn();
|
||||||
|
testColumnsRoundTripWhereFramesShareThem();
|
||||||
|
|
||||||
testMarkerAtPointGrabsWithinBand();
|
testMarkerAtPointGrabsWithinBand();
|
||||||
testMarkerAtPointMissesBetween();
|
testMarkerAtPointMissesBetween();
|
||||||
@@ -615,12 +817,15 @@ int main() {
|
|||||||
|
|
||||||
testResolveDragFrameShift();
|
testResolveDragFrameShift();
|
||||||
testResolveDragFrameClamps();
|
testResolveDragFrameClamps();
|
||||||
testResolveDragFrameRounds();
|
testResolveDragFrameTruncatesAtFractionalScale();
|
||||||
testResolveDragFrameDegenerate();
|
testResolveDragFrameDegenerate();
|
||||||
|
testResolveDragFrameLandsOnCursorColumn();
|
||||||
|
|
||||||
testZeroCrossingNearest();
|
testZeroCrossingNearest();
|
||||||
testZeroCrossingSampleOnZero();
|
testZeroCrossingSampleOnZero();
|
||||||
testZeroCrossingEquidistantTieToLower();
|
testZeroCrossingEquidistantTieToLower();
|
||||||
|
testZeroCrossingTiesAlwaysResolveToTheLowerFrame();
|
||||||
|
testZeroCrossingTakesTheNearerSideFromEitherDirection();
|
||||||
testZeroCrossingNoneKeepsTarget();
|
testZeroCrossingNoneKeepsTarget();
|
||||||
testZeroCrossingClampsTarget();
|
testZeroCrossingClampsTarget();
|
||||||
testZeroCrossingDegenerate();
|
testZeroCrossingDegenerate();
|
||||||
@@ -629,6 +834,7 @@ int main() {
|
|||||||
testSurfaceMonoIsOneLane();
|
testSurfaceMonoIsOneLane();
|
||||||
testSurfaceMonoSourceInStereoModeStaysOneLane();
|
testSurfaceMonoSourceInStereoModeStaysOneLane();
|
||||||
testSurfaceOverlayIsFullStackedHeightInBothModes();
|
testSurfaceOverlayIsFullStackedHeightInBothModes();
|
||||||
|
testTheOverlayIsExactlyTheDrawnColumnBand();
|
||||||
testSurfaceDegenerateBandDrawsNothing();
|
testSurfaceDegenerateBandDrawsNothing();
|
||||||
testSurfaceThinBandRoundsLowerLaneEmpty();
|
testSurfaceThinBandRoundsLowerLaneEmpty();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user