fix(instrument): stop Ctrl-before-click stealing a waveform mark grab from a coincident node
Ctrl held before mouse-down forced the node/toggle win over a smaller cap or column regardless of area; now it defers to the ordinary smallest-area arbitration like a plain click. Also amends product docs, VERIFICATION.md, and adds sparse-material/narrow-overlay test fixtures.
This commit is contained in:
+15
-1
@@ -1,6 +1,6 @@
|
|||||||
# DAW verification — post-1.0 work on `dev`
|
# DAW verification — post-1.0 work on `dev`
|
||||||
|
|
||||||
Checks for Θ, Ξ, Ψ, Ε, Ρ, and Γ work that no unit test can close. Build **Release**, install into
|
Checks for Θ, Ξ, Ψ, Ε, Ρ, Γ, and Ω work that no unit test can close. Build **Release**, install into
|
||||||
`UserPlugins/`, restart REAPER. Panel tail toggle = **None**, project rate 48000, unless a check says otherwise.
|
`UserPlugins/`, restart REAPER. Panel tail toggle = **None**, project rate 48000, unless a check says otherwise.
|
||||||
|
|
||||||
## Precision invariants
|
## Precision invariants
|
||||||
@@ -167,6 +167,20 @@ Checks for Θ, Ξ, Ψ, Ε, Ρ, and Γ work that no unit test can close. Build **
|
|||||||
- [ ] Open a project saved before the zone retirement — the instance reopens on its first zone and sounds the same (`docs/COMPLETED.md` §"Θ-W1-T1")
|
- [ ] Open a project saved before the zone retirement — the instance reopens on its first zone and sounds the same (`docs/COMPLETED.md` §"Θ-W1-T1")
|
||||||
- [ ] Such an instance with implicit channel mode + a stereo capture reopens **Stereo** — confirm that is acceptable by ear (`docs/COMPLETED.md` §"Θ-W1-T1")
|
- [ ] Such an instance with implicit channel mode + a stereo capture reopens **Stereo** — confirm that is acceptable by ear (`docs/COMPLETED.md` §"Θ-W1-T1")
|
||||||
|
|
||||||
|
## Waveform marker zero-crossing snap (Ω-W2-T5)
|
||||||
|
|
||||||
|
- [ ] `[verify — DAW]` Drag a Gate loop mark on a single-cycle capture — the felt radius the mark
|
||||||
|
may be pulled to reach a crossing genuinely reads as imperceptible, i.e. a displacement inside
|
||||||
|
the pointer's own grab neighbourhood is not one the eye catches (`src/core/instrument/ui/waveform_view.h`'s
|
||||||
|
`kZeroCrossingSnapPx` comment)
|
||||||
|
- [ ] `[verify — DAW]` Hold Ctrl, then drag a waveform marker (start/loop start/loop end) off a
|
||||||
|
zero crossing — confirm it lands exactly where dropped, with no snap, in a live editor window
|
||||||
|
(`editor_input_waveform.cpp`'s `dragWaveform`, `GetKeyState`-driven, no CI test target)
|
||||||
|
- [ ] `[verify — DAW]` Press and hold Ctrl BEFORE mouse-down on a mark that coincides with a
|
||||||
|
contour node or another mark's cap — confirm the mark is grabbed (not the node dragged, not a
|
||||||
|
hard/smooth toggle fired), matching the fix in `resolveWaveformClaim`
|
||||||
|
(`src/core/instrument/ui/spline_edit.cpp`)
|
||||||
|
|
||||||
## Look and feel
|
## Look and feel
|
||||||
|
|
||||||
- [ ] Sign off by eye in a live editor window: knob arcs, needles, envelope splines, waveform outline (`docs/COMPLETED.md` §"Θ-W7-T1", `docs/COMPLETED.md` §"Θ-W6-T1" — "Antialiasing pass")
|
- [ ] Sign off by eye in a live editor window: knob arcs, needles, envelope splines, waveform outline (`docs/COMPLETED.md` §"Θ-W7-T1", `docs/COMPLETED.md` §"Θ-W6-T1" — "Antialiasing pass")
|
||||||
|
|||||||
@@ -829,6 +829,19 @@ model"* — a snap available on one and not the others would be a divergence.
|
|||||||
> "whole number" above the frame. Overloading Shift there would collide with a shipped
|
> "whole number" above the frame. Overloading Shift there would collide with a shipped
|
||||||
> gesture. Recorded as a deliberate exclusion.
|
> gesture. Recorded as a deliberate exclusion.
|
||||||
|
|
||||||
|
> **AMENDED (Ω-W2-T5, Daniel 2026-08-03) — Ctrl now has a marker meaning too, and it is not
|
||||||
|
> this table's Ctrl row.** Dragging a waveform marker snaps it to the nearest zero crossing
|
||||||
|
> within a small pixel radius (`core/instrument/ui/waveform_view`'s `snapToZeroCrossing`);
|
||||||
|
> holding Ctrl defeats that snap outright rather than scaling the drag by 0.05. This does not
|
||||||
|
> reopen the exclusion above or the Shift question it answers: the exclusion is about **Shift**
|
||||||
|
> colliding with the shipped zero-crossing gesture on a domain with no meaningful whole number
|
||||||
|
> to snap to, and that reasoning is untouched — Ctrl's marker meaning is a different modifier
|
||||||
|
> defeating a different gesture (an existing snap, not this section's taper), not a second
|
||||||
|
> "whole number" law for frames. The two Ctrl meanings (defeat-the-snap here, fine-drag in
|
||||||
|
> §4.1's own table) do not collide in practice because they land on disjoint control families —
|
||||||
|
> a marker is never also a unit-valued knob — but the marker's own Ctrl is a genuinely separate
|
||||||
|
> semantic and is recorded as such, not folded into the row above.
|
||||||
|
|
||||||
### 4.2 The snap unit, by category
|
### 4.2 The snap unit, by category
|
||||||
|
|
||||||
| Unit category | Controls | Shift snaps to |
|
| Unit category | Controls | Shift snaps to |
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ anything for a trigger shape.
|
|||||||
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, loop enable, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
|
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, loop enable, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
|
||||||
- `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 the zero-crossing snap — RADIUS-BOUNDED (`snapToZeroCrossing`), the radius denominated in PIXELS and converted through `xToFrame`; `nearestZeroCrossing` beside it is the unbounded primitive and is NOT what a drag applies — 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, plus generic named draggable markers with a drag-delta resolver, clamp, and the zero-crossing snap. The snap is RADIUS-BOUNDED (`snapToZeroCrossing`), the radius denominated in PIXELS and converted through `xToFrame`; `nearestZeroCrossing` beside it is the unbounded primitive and is NOT what a drag applies. Ctrl defeats the snap outright at the shell (`editor_input_waveform.cpp`), the same "get out of the way, I want exactness" role it plays on the knobs' fine drag. `markerHandleRect` is 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 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.
|
- **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. 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.
|
- **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.
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ VelocityCurve::Box splineOverlayBox(const OverlayArea& area) {
|
|||||||
|
|
||||||
WaveformClaimant resolveWaveformClaim(const WaveformClaim& node, const WaveformClaim& tab,
|
WaveformClaimant resolveWaveformClaim(const WaveformClaim& node, const WaveformClaim& tab,
|
||||||
const WaveformClaim& marker, SplineGesture gesture) {
|
const WaveformClaim& marker, SplineGesture gesture) {
|
||||||
if (gesture == SplineGesture::kControlLeft && node.hit) return WaveformClaimant::kNode;
|
// The override is for a node with NOTHING else in the running: a cap or column also hit
|
||||||
|
// must fall through to the ordinary smallest-area arbitration below, exactly like a plain
|
||||||
|
// left-click, so Ctrl pressed before the button can never steal a mark grab out from under
|
||||||
|
// the cursor.
|
||||||
|
if (gesture == SplineGesture::kControlLeft && node.hit && !tab.hit && !marker.hit)
|
||||||
|
return WaveformClaimant::kNode;
|
||||||
if (node.hit && (!tab.hit || node.area <= tab.area) && (!marker.hit || node.area <= marker.area))
|
if (node.hit && (!tab.hit || node.area <= tab.area) && (!marker.hit || node.area <= marker.area))
|
||||||
return WaveformClaimant::kNode;
|
return WaveformClaimant::kNode;
|
||||||
if (tab.hit && (!marker.hit || tab.area <= marker.area)) return WaveformClaimant::kTab;
|
if (tab.hit && (!marker.hit || tab.area <= marker.area)) return WaveformClaimant::kTab;
|
||||||
|
|||||||
@@ -75,8 +75,11 @@ enum class WaveformClaimant { kNone, kNode, kTab, kMarker };
|
|||||||
// editor_input_waveform.cpp's mouseDownWaveform for the live constants). Every mark's cap is
|
// editor_input_waveform.cpp's mouseDownWaveform for the live constants). Every mark's cap is
|
||||||
// one markerHandleRect, so the `tab` slot carries ONE nominal area however many marks feed it;
|
// one markerHandleRect, so the `tab` slot carries ONE nominal area however many marks feed it;
|
||||||
// which mark it resolves to is waveform_view's capAtPoint, not this. A control-click has no
|
// which mark it resolves to is waveform_view's capAtPoint, not this. A control-click has no
|
||||||
// cap/column meaning (they answer plain grabs only), so it resolves to the node whenever the
|
// cap/column meaning (only the node's hard/smooth toggle answers it), so it claims the node
|
||||||
// node is in the running, regardless of area.
|
// outright ONLY when no cap or column also hits — a cap or column in the running instead falls
|
||||||
|
// through to the ordinary smallest-area arbitration below, same as a plain click. Ctrl must
|
||||||
|
// never be able to steal a mark grab out from under the cursor merely because it was pressed
|
||||||
|
// before the button.
|
||||||
WaveformClaimant resolveWaveformClaim(const WaveformClaim& node, const WaveformClaim& tab,
|
WaveformClaimant resolveWaveformClaim(const WaveformClaim& node, const WaveformClaim& tab,
|
||||||
const WaveformClaim& marker, SplineGesture gesture);
|
const WaveformClaim& marker, SplineGesture gesture);
|
||||||
|
|
||||||
|
|||||||
@@ -193,14 +193,19 @@ std::int64_t resolveDragFrame(const OverlayArea& area, std::int64_t frameCount,
|
|||||||
// frame. No sign change anywhere (or fewer than 2 frames) returns the clamped target unchanged.
|
// frame. No sign change anywhere (or fewer than 2 frames) returns the clamped target unchanged.
|
||||||
//
|
//
|
||||||
// This answers WHERE the nearest crossing is, over the whole buffer. It is not what a drag
|
// This answers WHERE the nearest crossing is, over the whole buffer. It is not what a drag
|
||||||
// should apply — see snapToZeroCrossing.
|
// should apply — see snapToZeroCrossing. No production caller: the drag applies the bounded
|
||||||
|
// entry. Keep this one anyway — it is the differential oracle the radius tests check the bounded
|
||||||
|
// entry against (dense material must equal it exactly; sparse and single-cycle material must
|
||||||
|
// not), and re-adopting it as a drag snap is the exact defect snapToZeroCrossing's own header
|
||||||
|
// warns against.
|
||||||
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
||||||
std::int64_t target);
|
std::int64_t target);
|
||||||
|
|
||||||
// How far, in PIXELS, a mark may be pulled to reach a crossing. Aliases the mark's own grab band
|
// How far, in PIXELS, a mark may be pulled to reach a crossing. Aliases the mark's own grab band
|
||||||
// on purpose: the snap may move a mark only as far as the pointer could have been from it and
|
// on purpose: the snap may move a mark only as far as the pointer could have been from it and
|
||||||
// still have grabbed it — a displacement inside the neighbourhood the user was already pointing
|
// still have grabbed it. `[verify — DAW]` a displacement inside the neighbourhood the user was
|
||||||
// at is one they cannot see.
|
// already pointing at is one they cannot see — asserted from the geometry, not observed by eye
|
||||||
|
// in a live editor window.
|
||||||
//
|
//
|
||||||
// PIXELS rather than frames because the drag itself resolves in pixels — resolveDragFrame answers
|
// PIXELS rather than frames because the drag itself resolves in pixels — resolveDragFrame answers
|
||||||
// the column's own first frame, so a frame radius under one column's span would be unreachable on
|
// the column's own first frame, so a frame radius under one column's span would be unreachable on
|
||||||
|
|||||||
@@ -311,3 +311,11 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
|
|||||||
"channel identity derives from `app_version` accessors, no scattered `#ifdef`s" —
|
"channel identity derives from `app_version` accessors, no scattered `#ifdef`s" —
|
||||||
`INLINE_UID` needs literal brace-init tokens, so it can't route through a runtime
|
`INLINE_UID` needs literal brace-init tokens, so it can't route through a runtime
|
||||||
string accessor.
|
string accessor.
|
||||||
|
- **Ctrl held during a waveform marker drag defeats the zero-crossing snap outright**
|
||||||
|
(`editor_input_waveform.cpp`'s `dragWaveform`, reading `dragModifiers().ctrl` — the same
|
||||||
|
"get out of the way, I want exactness" role Ctrl plays as the knobs' fine drag). It is
|
||||||
|
undiscoverable by design — no cursor cue, no label — so this note is its only home; the
|
||||||
|
radius itself (`core/instrument/ui/waveform_view`'s `snapToZeroCrossing`) is documented there,
|
||||||
|
not here. `resolveWaveformClaim` (`core/instrument/ui/spline_edit`) is the reason Ctrl held
|
||||||
|
BEFORE mouse-down still reaches the mark rather than stealing a coincident envelope-node grab
|
||||||
|
or hard/smooth toggle.
|
||||||
|
|||||||
@@ -286,10 +286,11 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
|
|||||||
// where the mark was dropped. Pure over the cached mono frames — no host types, no file I/O.
|
// where the mark was dropped. Pure over the cached mono frames — no host types, no file I/O.
|
||||||
// The crossfade handle is exempt: it sets a fade LENGTH, and the whole point of the fade is
|
// The crossfade handle is exempt: it sets a fade LENGTH, and the whole point of the fade is
|
||||||
// that its edges need no zero crossing. Ctrl defeats the snap outright, the same "get out of
|
// that its edges need no zero crossing. Ctrl defeats the snap outright, the same "get out of
|
||||||
// the way, I want exactness" role it plays as the knobs' fine drag.
|
// the way, I want exactness" role it plays as the knobs' fine drag. `[verify — DAW]` this
|
||||||
|
// `GetKeyState` read has no test target — nothing in CI observes the defeat actually landing.
|
||||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||||
if (!pcm.empty() && waveMarker_ != WaveMarker::kCrossfade && !dragModifiers().ctrl) {
|
if (!pcm.empty() && waveMarker_ != WaveMarker::kCrossfade && !dragModifiers().ctrl) {
|
||||||
newFrame = snapToZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()), newFrame,
|
newFrame = snapToZeroCrossing(pcm.data(), frames, newFrame,
|
||||||
zeroCrossingSnapFrames(overlay, frames));
|
zeroCrossingSnapFrames(overlay, frames));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -280,15 +280,27 @@ static void testAMissedCandidateNeverWinsOnADegenerateZeroArea() {
|
|||||||
WaveformClaimant::kTab);
|
WaveformClaimant::kTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A control-click has no tab/marker meaning (only the node's hard/smooth toggle answers it), so
|
// A control-click has no tab/marker meaning (only the node's hard/smooth toggle answers it), but
|
||||||
// it resolves to the node whenever the node is in the running, even where a plain left-click at
|
// that must not let Ctrl steal a mark grab out from under the cursor: when a cap or column is
|
||||||
// the same pixel would hand the tab or marker the win on area alone.
|
// ALSO in the running, control-click defers to the ordinary smallest-area arbitration exactly
|
||||||
static void testControlClickAlwaysTakesTheNodeOverASmallerTabOrMarker() {
|
// like a plain left-click would, so pressing Ctrl before or after the button gives the same
|
||||||
|
// answer. Only with no cap/column in the running at all does control-click claim the node
|
||||||
|
// outright regardless of area.
|
||||||
|
static void testControlClickDefersToACoincidentCapOrColumnLikeAPlainClick() {
|
||||||
const WaveformClaim node{true, kNodeArea};
|
const WaveformClaim node{true, kNodeArea};
|
||||||
const WaveformClaim smallerTab{true, 50}; // would beat the node on a plain left-click
|
const WaveformClaim smallerTab{true, 50}; // would beat the node on a plain left-click
|
||||||
CHECK(resolveWaveformClaim(node, smallerTab, WaveformClaim{}, SplineGesture::kLeft) ==
|
CHECK(resolveWaveformClaim(node, smallerTab, WaveformClaim{}, SplineGesture::kLeft) ==
|
||||||
WaveformClaimant::kTab);
|
WaveformClaimant::kTab);
|
||||||
|
// Ctrl pressed before the click must not out-rank the cap that a plain click already gives
|
||||||
|
// the win — the exact regression this pins.
|
||||||
CHECK(resolveWaveformClaim(node, smallerTab, WaveformClaim{}, SplineGesture::kControlLeft) ==
|
CHECK(resolveWaveformClaim(node, smallerTab, WaveformClaim{}, SplineGesture::kControlLeft) ==
|
||||||
|
WaveformClaimant::kTab);
|
||||||
|
const WaveformClaim smallerMarker{true, 80}; // still smaller than the node, no tab present
|
||||||
|
CHECK(resolveWaveformClaim(node, WaveformClaim{}, smallerMarker,
|
||||||
|
SplineGesture::kControlLeft) == WaveformClaimant::kMarker);
|
||||||
|
// No cap or column at all: control-click still claims the node outright, regardless of area
|
||||||
|
// — there is nothing else for it to defer to.
|
||||||
|
CHECK(resolveWaveformClaim(node, WaveformClaim{}, WaveformClaim{}, SplineGesture::kControlLeft) ==
|
||||||
WaveformClaimant::kNode);
|
WaveformClaimant::kNode);
|
||||||
// No node in the running: control-click has nothing to fall back to, so the tab still wins.
|
// No node in the running: control-click has nothing to fall back to, so the tab still wins.
|
||||||
CHECK(resolveWaveformClaim(WaveformClaim{}, smallerTab, WaveformClaim{},
|
CHECK(resolveWaveformClaim(WaveformClaim{}, smallerTab, WaveformClaim{},
|
||||||
@@ -311,7 +323,7 @@ int main() {
|
|||||||
testTabWinsAGenuineTabVersusMarkerTie();
|
testTabWinsAGenuineTabVersusMarkerTie();
|
||||||
testNoHitAnywhereFallsThroughToNone();
|
testNoHitAnywhereFallsThroughToNone();
|
||||||
testAMissedCandidateNeverWinsOnADegenerateZeroArea();
|
testAMissedCandidateNeverWinsOnADegenerateZeroArea();
|
||||||
testControlClickAlwaysTakesTheNodeOverASmallerTabOrMarker();
|
testControlClickDefersToACoincidentCapOrColumnLikeAPlainClick();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("spline_edit: all tests passed\n");
|
if (g_fail == 0) std::printf("spline_edit: all tests passed\n");
|
||||||
return g_fail == 0 ? 0 : 1;
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
|||||||
@@ -394,6 +394,45 @@ static void testSnapRadiusIsThePixelBandsOwnFrameSpan() {
|
|||||||
CHECK(zeroCrossingSnapFrames(overlayOf(Rect::ltrb(0, 0, 0, 60)), 1000) == 0); // zero width
|
CHECK(zeroCrossingSnapFrames(overlayOf(Rect::ltrb(0, 0, 0, 60)), 1000) == 0); // zero width
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A narrower-than-kZeroCrossingSnapPx overlay pushes `area.x + kZeroCrossingSnapPx` past
|
||||||
|
// area.right(), so xToFrame answers frameCount (its own past-the-edge clamp) and the "radius"
|
||||||
|
// becomes the WHOLE buffer — the original unbounded-snap defect, on a width the band-stack
|
||||||
|
// allocator's kEditorMinWidth floor never actually produces in the shipped editor. Documented as
|
||||||
|
// a fixture rather than left implicit, since this is a public pure API and the width sweep
|
||||||
|
// elsewhere in this file jumps straight from 0 to 1000.
|
||||||
|
static void testNarrowOverlayLosesTheBoundBelowTheSnapWidth() {
|
||||||
|
const Rect a = Rect::ltrb(0, 0, 3, 60); // narrower than kZeroCrossingSnapPx (5)
|
||||||
|
CHECK(zeroCrossingSnapFrames(overlayOf(a), 100000) == 100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long SPARSE material: a 1 s / 48 kHz 40 Hz square wave, crossings ~600 frames apart, drawn
|
||||||
|
// 1000 px wide (r = 240). The dense sweep above holds every crossing well inside the radius by
|
||||||
|
// construction, so it can never observe the bound; this is the only fixture where the radius
|
||||||
|
// sits strictly BETWEEN two crossings on genuinely long material, so the bounded and unbounded
|
||||||
|
// searches can actually disagree.
|
||||||
|
static void testSnapBoundsALongSparseCaptureBetweenCrossings() {
|
||||||
|
constexpr std::int64_t n = 48000, kHalfPeriod = 600; // 40 Hz square wave at 48 kHz
|
||||||
|
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); // 240
|
||||||
|
CHECK(r < kHalfPeriod); // strictly between two crossings, not covering either
|
||||||
|
|
||||||
|
// Equidistant midpoint between the crossings at 600 and 1200: the unbounded search still
|
||||||
|
// finds one (the tie rule picks the lower, 600), while the bounded snap correctly leaves the
|
||||||
|
// mark where it was dropped — this pair IS the observable difference on long material.
|
||||||
|
const std::int64_t crossing = kHalfPeriod, midpoint = crossing + kHalfPeriod / 2;
|
||||||
|
CHECK(nearestZeroCrossing(pcm.data(), n, midpoint) == crossing);
|
||||||
|
CHECK(snapToZeroCrossing(pcm.data(), n, midpoint, r) == midpoint);
|
||||||
|
|
||||||
|
// Inside the radius the snap still reaches its crossing, same as ever.
|
||||||
|
CHECK(snapToZeroCrossing(pcm.data(), n, crossing + r, r) == crossing);
|
||||||
|
CHECK(snapToZeroCrossing(pcm.data(), n, crossing - r, r) == crossing);
|
||||||
|
}
|
||||||
|
|
||||||
// One cycle of a 60 Hz sine at 48 kHz — 800 frames, and exactly ONE interior sign change, at the
|
// 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
|
// 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
|
// single crossing IS the reported defect: an unbounded search resolves every drop in the buffer
|
||||||
@@ -944,8 +983,10 @@ int main() {
|
|||||||
testZeroCrossingClampsTarget();
|
testZeroCrossingClampsTarget();
|
||||||
testZeroCrossingDegenerate();
|
testZeroCrossingDegenerate();
|
||||||
testSnapRadiusIsThePixelBandsOwnFrameSpan();
|
testSnapRadiusIsThePixelBandsOwnFrameSpan();
|
||||||
|
testNarrowOverlayLosesTheBoundBelowTheSnapWidth();
|
||||||
testSnapLeavesASingleCycleMarkWhereItWasDropped();
|
testSnapLeavesASingleCycleMarkWhereItWasDropped();
|
||||||
testSnapIsUnchangedOnDenseMaterial();
|
testSnapIsUnchangedOnDenseMaterial();
|
||||||
|
testSnapBoundsALongSparseCaptureBetweenCrossings();
|
||||||
testSnapTakesACrossingAtTheRadiusAndRefusesOnePastIt();
|
testSnapTakesACrossingAtTheRadiusAndRefusesOnePastIt();
|
||||||
testSnapKeepsTheTieRuleInsideTheRadius();
|
testSnapKeepsTheTieRuleInsideTheRadius();
|
||||||
testSnapAtZeroRadiusMovesNothingButAnExactHit();
|
testSnapAtZeroRadiusMovesNothingButAnExactHit();
|
||||||
|
|||||||
Reference in New Issue
Block a user