diff --git a/docs/TODO.md b/docs/TODO.md index 27a93a6..a36407c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -172,13 +172,7 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r **The wart.** A zero-attack `AttackEnd` vertex is drawn at the same pixel as `Origin` (the envelope's non-draggable start anchor), which for an AHD envelope sits at the start marker's frame. Because a node's nominal pick-box area is smaller than the marker's full-height grab-column area, and `resolveWaveformClaim`'s rule is "smallest area among hit candidates wins," the draggable `AttackEnd` node still claims the click over the start marker when the two coincide — and, at a loop starting there, over the crossfade tab. Folding the staged pass into the shared arbitration slot did not change this specific outcome, since the rule that decides node-vs-marker priority is unchanged from what the contour-node fix established. `Origin` itself is excluded from `nodeAtPoint`'s candidate set entirely (never draggable, never a hit), so the common case — attack > 0, no coincidence — is unaffected. -**Intended fix.** Not yet proposed. Bringing the staged pass into the shared arbitration slot was the natural first step and has landed; closing the remaining collision needs either a per-affordance priority rule for genuinely coincident precision targets, or accepting the current smallest-area outcome as intended and documenting it as such rather than as an open wart. - -**The constraint the fix MUST handle.** Whatever rule changes must not regress the contour-node/marker and tab/marker arbitration W5 already fixed, and must not make `Origin` draggable or otherwise touch `isDraggable`'s AHD/AHDSR shape rules. - -**Priority / risk.** Low. Pre-existing, not introduced by W5; the common case (nonzero attack) is unaffected, and the collision requires both a zero-attack stage and a coincident marker/tab to be reachable at all. - -**Done looks like.** A zero-attack `AttackEnd` node coincident with the start marker (or, on a loop starting there, the crossfade tab) no longer silently claims the click ahead of the marker/tab — either by an explicit priority rule or by a recorded decision that the current behavior is intended. +**RESOLVED — Γ-W2-T2 (`loop-crossfade-ux`), incidentally.** Giving every mark the cap-grip the crossfade already had is what closed it: the start marker now carries an 11x10 cap in the overlay's top strip, whose nominal area (110) is smaller than the node's fixed pick box (169), so the cap wins the coincident pixel and the marker is reachable again. No priority rule was added and `resolveWaveformClaim` is byte-for-byte unchanged — every cap is one `markerHandleRect`, so the cap slot's nominal area did not move and the `cap < node < column` ordering still holds. Below the cap strip the node keeps the click, which is correct: that is where the node is actually drawn for any non-degenerate envelope. Pinned by `testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip` (`tests/test_spline_edit.cpp`). `Origin` was not touched and `isDraggable`'s shape rules are unchanged. ## Active-bank indicator placement (B4 polish) diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index a852599..81b683e 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -326,11 +326,13 @@ anything for a trigger shape. - `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own. - `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack. -- `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, 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. - `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: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, 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) plus **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. + - **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. +- `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. - `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search. - `param_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required. diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 9c428ed..f19dbef 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -29,6 +29,11 @@ reasampler_pure_library(waveform_view # waveform_view does not re-export. reasampler_test(waveform_view LINK waveform_view sample_bands) +# 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. +reasampler_pure_library(loop_marks SOURCES loop_marks.cpp LINK PUBLIC loop_span) +reasampler_test(loop_marks LINK loop_marks) + reasampler_pure_library(browser_scroll SOURCES browser_scroll.cpp LINK PUBLIC capture_browser sample_chrome) diff --git a/src/core/instrument/ui/loop_marks.cpp b/src/core/instrument/ui/loop_marks.cpp new file mode 100644 index 0000000..82cc51a --- /dev/null +++ b/src/core/instrument/ui/loop_marks.cpp @@ -0,0 +1,55 @@ +// loop_marks.cpp — see loop_marks.h. Pure value folds; no host types. + +#include "core/instrument/ui/loop_marks.h" + +#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds + +namespace reasampler::instrument::ui { + +namespace { + +// The engine's own acceptance test, restated over the editor's two integers: resolveLoop +// refuses an inverted, empty or out-of-range span rather than repairing it, so a span it would +// refuse is one the handles must be re-parked out of. +bool spanUsable(std::int64_t loopStart, std::int64_t loopEnd, std::int64_t frameCount) { + return loopStart >= 0 && loopEnd > loopStart && loopEnd <= frameCount; +} + +} // namespace + +LoopMarks resolveLoopMarks(const StoredLoop& stored, std::int64_t frameCount) { + LoopMarks m; + if (stored.override_) { + m.hasLoop = stored.override_->hasLoop; + m.loopStart = stored.override_->start; + m.loopEnd = stored.override_->end; + } else if (stored.intrinsic && stored.intrinsic->hasLoop) { + m.hasLoop = true; + m.loopStart = stored.intrinsic->start; + m.loopEnd = stored.intrinsic->end; + } + if (stored.startPoint) m.start = *stored.startPoint; + m.crossfade = stored.crossfade > 0 ? stored.crossfade : 0; + + if (!spanUsable(m.loopStart, m.loopEnd, frameCount)) { + m.hasLoop = false; + m.parked = true; + const engine::loop::LoopBounds d = engine::loop::defaultLoopBounds(frameCount); + m.loopStart = d.start; + m.loopEnd = d.end; + } + return m; +} + +LoopWrite applyLoopMarks(const LoopMarks& m) { + LoopWrite w; + const bool spanAlive = m.loopEnd > m.loopStart; + w.loop.hasLoop = m.hasLoop && spanAlive; + w.loop.start = m.loopStart; + w.loop.end = m.loopEnd; + w.crossfade = (spanAlive && m.crossfade > 0) ? m.crossfade : 0; + w.start = m.start; + return w; +} + +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/loop_marks.h b/src/core/instrument/ui/loop_marks.h new file mode 100644 index 0000000..773cbde --- /dev/null +++ b/src/core/instrument/ui/loop_marks.h @@ -0,0 +1,60 @@ +#pragma once +// loop_marks.h — the loop enable's state machine: what the waveform band SHOWS for a stored +// loop, and what a marker edit WRITES back. `SampleLoop::hasLoop` is the single authority; +// collapse-to-off and drag-to-create are shortcuts onto it, not a second state. Pure values +// only — the shell supplies the stored side and applies the result. + +#include +#include + +#include "core/instrument/engine/play_params.h" // SampleLoop + +namespace reasampler::instrument::ui { + +using ::reasampler::SampleLoop; + +// What the four marks are showing. `parked` separates the two OFF states: a pair sitting on +// defaultLoopBounds because nothing was ever set (there is a loop to "set") from a real span +// the user switched off (there is not). +struct LoopMarks { + std::int64_t start = 0; + std::int64_t loopStart = 0; + std::int64_t loopEnd = 0; + std::int64_t crossfade = 0; // pre-seam fade, SOURCE frames + bool hasLoop = false; + bool parked = false; +}; + +// The stored side: the parameter set's loop override, the bank's loop intrinsic (consulted only +// when there is no override — the override always supersedes it), the crossfade length, and the +// start point. +struct StoredLoop { + std::optional override_; + std::optional intrinsic; + std::int64_t crossfade = 0; + std::optional startPoint; +}; + +// Reads the stored loop into what the band shows. A span the engine could not honour — +// collapsed, inverted, or outside [0, frameCount] — re-parks on defaultLoopBounds so two +// coincident handles can never become ungrabbable; a VALID span keeps its own positions +// whatever the enable says, which is what makes the enable a toggle rather than a delete +// button. +LoopMarks resolveLoopMarks(const StoredLoop& stored, std::int64_t frameCount); + +// The write side, the inverse of resolveLoopMarks. +struct LoopWrite { + SampleLoop loop; + std::int64_t crossfade = 0; + std::int64_t start = 0; +}; + +// Folds collapse-to-off in: a span dragged onto itself is the OFF gesture, recorded as such so +// the next resolve re-offers the default handles. The crossfade goes with the SPAN, not with +// the enable — zeroed only when the span is destroyed. That preserves the original zeroing +// rule's reason rather than overruling it: a stale length could silently re-apply against a +// span that no longer exists, but a retained span retains its clamp bound too, so nothing is +// stale. +LoopWrite applyLoopMarks(const LoopMarks& m); + +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/sample_chrome.cpp b/src/core/instrument/ui/sample_chrome.cpp index 21ac836..20c2004 100644 --- a/src/core/instrument/ui/sample_chrome.cpp +++ b/src/core/instrument/ui/sample_chrome.cpp @@ -19,6 +19,10 @@ constexpr int kStripBandHeight = 30; constexpr int kRunGap = 6; // between adjacent items of the toolbar run constexpr int kChanSegW = 52; constexpr int kChanSegH = 18; +// The loop enable's segments carry a two-word label, so they are wider than Mono|Stereo's. +// If the title slot ever fails to hold its text at the editor's floor, THIS narrows — the +// floor does not move. +constexpr int kLoopSegW = 58; constexpr int kVelCellW = 56; constexpr int kHoldCellW = 56; // the bake Hold cell, same grammar as the velocity cell constexpr int kVelLabelH = 16; @@ -43,7 +47,8 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) { const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; }; const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); }; - // The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview, bake, hold. + // The fixed run, right to left: Browse, Mono|Stereo, Loop Off|On, velocity cell, preview, + // bake, hold. // The velocity-curve button that used to sit here now lives in the deck's VELOCITY group. const int navH = std::min(kRunButtonH, row.height); const int navTop = topFor(navH); @@ -58,9 +63,15 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) { r.chanMono = Rect::ltrb(leftOf(r.chanStereo.x, kChanSegW), chanTop, r.chanStereo.x, chanTop + kChanSegH); + const int loopRight = leftOf(r.chanMono.x, kRunGap); + r.loopOn = Rect::ltrb(leftOf(loopRight, kLoopSegW), chanTop, loopRight, + chanTop + kChanSegH); + r.loopOff = Rect::ltrb(leftOf(r.loopOn.x, kLoopSegW), chanTop, r.loopOn.x, + chanTop + kChanSegH); + const int cellH = std::min(row.height, knobSize + kVelLabelH); const int cellTop = topFor(cellH); - const int cellRight = leftOf(r.chanMono.x, kRunGap); + const int cellRight = leftOf(r.loopOff.x, kRunGap); r.velCell = Rect::ltrb(leftOf(cellRight, kVelCellW), cellTop, cellRight, cellTop + cellH); const int knobLeft = r.velCell.x + (r.velCell.width - knobSize) / 2; r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize, diff --git a/src/core/instrument/ui/sample_chrome.h b/src/core/instrument/ui/sample_chrome.h index bf66034..28e684c 100644 --- a/src/core/instrument/ui/sample_chrome.h +++ b/src/core/instrument/ui/sample_chrome.h @@ -2,7 +2,8 @@ // sample_chrome.h — interior geometry of the Sample face's CHROME band: the toolbar row // (title + the whole right-anchored control run + Browse) over the strip row, which the // piano strip has to itself. Reads the band rect the allocator hands it (sample_bands) and -// never allocates vertical space of its own. +// never allocates vertical space of its own. The run is right-anchored and the title takes +// the remainder, so a member added to the run costs the title, never the window's floor. #include "core/instrument/ui/editor_geometry.h" // Rect @@ -32,6 +33,11 @@ struct ChromeRects { Rect velCell; // preview-velocity knob cell (knob + label band) Rect velKnob; Rect velLabel; + // The sustain loop's enable. Immediately left of the channel toggle because it is the same + // class of control — a playback mode of the loaded capture — and because the run is + // right-anchored, so the title slot absorbs its width and the editor's floor does not move. + Rect loopOff; + Rect loopOn; Rect chanMono; Rect chanStereo; Rect navBrowse; diff --git a/src/core/instrument/ui/spline_edit.h b/src/core/instrument/ui/spline_edit.h index 246fa9b..35b522c 100644 --- a/src/core/instrument/ui/spline_edit.h +++ b/src/core/instrument/ui/spline_edit.h @@ -65,14 +65,16 @@ struct WaveformClaim { enum class WaveformClaimant { kNone, kNode, kTab, kMarker }; // The overlay's cross-affordance arbitration: a contour node (or, mutually exclusively, a -// staged envelope's drag node — both feed the same `node` slot), the loop crossfade tab, and a -// marker's full-height column can all claim the same pixel. Hit gates a candidate out -// entirely; among the ones that hit, the SMALLEST nominal area wins — the marker column is the -// odd one out (its target is the whole overlay height), so it only wins where nothing narrower -// also claims the click. Ties go to whichever is checked first: node, then tab, then marker — -// no live geometry produces a tie except tab-vs-marker, which the tab correctly wins (see -// editor_input_waveform.cpp's mouseDownWaveform for the live constants). A control-click has no -// tab/marker meaning (they answer plain grabs only), so it resolves to the node whenever the +// staged envelope's drag node — both feed the same `node` slot), a mark's CAP, and a mark's +// full-height column can all claim the same pixel. Hit gates a candidate out entirely; among +// the ones that hit, the SMALLEST nominal area wins — the column is the odd one out (its target +// is the whole overlay height), so it only wins where nothing narrower also claims the click. +// Ties go to whichever is checked first: node, then tab, then marker — no live geometry +// produces a tie except tab-vs-marker, which the tab correctly wins (see +// 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; +// 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 // node is in the running, regardless of area. WaveformClaimant resolveWaveformClaim(const WaveformClaim& node, const WaveformClaim& tab, const WaveformClaim& marker, SplineGesture gesture); diff --git a/src/core/instrument/ui/waveform_view.cpp b/src/core/instrument/ui/waveform_view.cpp index b3347fa..209eda2 100644 --- a/src/core/instrument/ui/waveform_view.cpp +++ b/src/core/instrument/ui/waveform_view.cpp @@ -79,6 +79,66 @@ Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int return Rect{left, r.y, right - left, std::min(kMarkerHandleHeight, r.height)}; } +int capAtPoint(const OverlayArea& area, std::int64_t frameCount, const WaveMarks& marks, int x, + int y) { + for (int i = kWaveMarkCount - 1; i >= 0; --i) { + if (!marks.present[i]) continue; + if (contains(markerHandleRect(area, frameCount, marks.frame[i]), x, y)) return i; + } + return -1; +} + +bool markLabelLeftOfLine(WaveMark m) { + return m == WaveMark::kLoopEnd || m == WaveMark::kCrossfade; +} + +Rect markLabelRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame, + bool leftOfLine, int textWidth) { + const Rect& r = area.rect; + if (r.empty() || textWidth <= 0 || textWidth > r.width) return Rect{}; + const int h = std::min(kMarkLabelHeight, r.height - kMarkerHandleHeight); + if (h <= 0) return Rect{}; + const int mx = frameToX(area, frameCount, frame); + int left = leftOfLine ? mx - kMarkLabelGap - textWidth : mx + kMarkLabelGap; + left = std::max(r.x, std::min(left, r.right() - textWidth)); + return Rect{left, r.y + kMarkerHandleHeight, textWidth, h}; +} + +WaveMarkLabels layoutMarkLabels(const OverlayArea& area, std::int64_t frameCount, + const WaveMarks& marks, const int* textWidth, int promoted) { + WaveMarkLabels out; + if (textWidth == nullptr) return out; + // The promoted mark first, then draw order. Every label shares one row, so "would overlap + // one already placed" reduces to a horizontal span test. + int order[kWaveMarkCount + 1] = {promoted, 0, 1, 2, 3}; + for (int slot = 0; slot < kWaveMarkCount + 1; ++slot) { + const int i = order[slot]; + if (i < 0 || i >= kWaveMarkCount) continue; + if (!marks.present[i] || !out.box[i].empty()) continue; + const Rect box = markLabelRect(area, frameCount, marks.frame[i], + markLabelLeftOfLine(static_cast(i)), + textWidth[i]); + if (box.empty()) continue; + bool clash = false; + for (int j = 0; j < kWaveMarkCount && !clash; ++j) { + clash = !out.box[j].empty() && box.x < out.box[j].right() && + out.box[j].x < box.right(); + } + if (!clash) out.box[i] = box; + } + return out; +} + +int crossfadeWedgeHeight(int x0, int x1, int x) { + const int w = x1 - x0; + if (w <= 0 || x < x0 || x >= x1) return 0; + if (w == 1) return kCrossfadeWedgePx; + // Normalized over w - 1 so the LAST drawn column lands exactly on the peak, the same + // reason crossfadeWeight normalizes over crossfade - 1 (loop_span.h). + const int d = x - x0; + return (kCrossfadeWedgePx * d + (w - 1) / 2) / (w - 1); +} + int markerAtPoint(const OverlayArea& area, std::int64_t frameCount, const std::int64_t* frames, int count, int x, int y) { if (count <= 0 || frames == nullptr) return -1; diff --git a/src/core/instrument/ui/waveform_view.h b/src/core/instrument/ui/waveform_view.h index e59002d..2d2f310 100644 --- a/src/core/instrument/ui/waveform_view.h +++ b/src/core/instrument/ui/waveform_view.h @@ -65,17 +65,75 @@ int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t fram // area.x yields 0; right of area.right() yields frameCount. std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x); -// A marker's grab HANDLE: a tab riding the top of the overlay, centred on the marker's x and -// clipped into the area. Distinct from the full-height grab COLUMN markerAtPoint answers, so -// two markers that share a frame stay independently grabbable — the handle owns the top -// strip, the column owns everything below it. Without that split, first-in-draw-order wins -// every coincident tie and the loser can never be dragged apart again. +// A marker's grab HANDLE — THE CAP, in the mark grammar's vocabulary: a tab riding the top of +// the overlay, centred on the marker's x and clipped into the area. Distinct from the +// full-height grab COLUMN markerAtPoint answers, so two markers that share a frame stay +// independently grabbable — the handle owns the top strip, the column owns everything below +// it. Without that split, first-in-draw-order wins every coincident tie and the loser can never +// be dragged apart again. Every mark's cap is this ONE rect shape; only the glyph drawn inside +// it differs, which is what lets the claim arbitration see a single nominal cap area. inline constexpr int kMarkerHandleHeight = 10; // Same half-width as the column's own grab band on purpose: the handle is that same grab // tolerance, just confined to the top strip, not an independent tuning. inline constexpr int kMarkerHandleHalfWidth = kMarkerGrabWidth; Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame); +// --- The overlay's four marks ------------------------------------------------------------- + +// The marks the overlay carries, in DRAW and COLUMN-hit order. The shell's WaveMarker aliases +// this, so the drag router and the geometry below cannot disagree about an ordinal. +enum class WaveMark { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCrossfade = 3, kCount = 4 }; +inline constexpr int kWaveMarkCount = static_cast(WaveMark::kCount); + +// Which marks are on screen and where. A mark that is not `present` is excluded from every +// answer below — with no loop set there is no crossfade mark to reach. +struct WaveMarks { + std::int64_t frame[kWaveMarkCount] = {0, 0, 0, 0}; + bool present[kWaveMarkCount] = {false, false, false, false}; +}; + +// Which mark's cap a point lands on, or -1. Caps resolve in the REVERSE of the column order +// markerAtPoint uses — crossfade, end, loop start, start — and that reversal is the whole +// separability argument: whichever mark of a coincident PAIR loses the cap still answers its +// own full-height column, and the crossfade, the one mark with no column at all, is first so +// nothing can shadow it. A coincident TRIPLE still strands its middle mark, exactly as the +// pre-cap tab/column split did. +int capAtPoint(const OverlayArea& area, std::int64_t frameCount, const WaveMarks& marks, + int x, int y); + +// Labels sit in the row directly below the caps, beside the mark's line: START and LOOP to the +// right of it, END and XFADE to the left, so a label never crosses into the span it bounds. +inline constexpr int kMarkLabelHeight = 10; +inline constexpr int kMarkLabelGap = 3; // between the mark's line and its text + +bool markLabelLeftOfLine(WaveMark m); + +// The box `textWidth` px of label occupies for a mark at `frame`. Nudged inside the area rather +// than clipped — half a label reads as a different mark's — and empty when it cannot fit. +Rect markLabelRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame, + bool leftOfLine, int textWidth); + +// The placed labels; an empty box is a label that is not drawn. `textWidth` is per mark, in the +// caller's own font (this module measures no text). `promoted` — a WaveMark ordinal, or -1 — +// is placed FIRST and so can never be the one suppressed: it is the mark the user is grabbing +// or hovering, i.e. the one they are asking about. +struct WaveMarkLabels { + Rect box[kWaveMarkCount]; +}; +WaveMarkLabels layoutMarkLabels(const OverlayArea& area, std::int64_t frameCount, + const WaveMarks& marks, const int* textWidth, int promoted); + +// The crossfade region's peak edge-wedge height. The region draws as a wedge at the overlay's +// top and bottom edges and NEVER as a second fill: it now sits INSIDE the loop span, where a +// translucent fill would stack on the loop fill over an already-accepted under-floor contrast +// pair (see editor_paint_waveform.cpp). +inline constexpr int kCrossfadeWedgePx = 10; + +// Wedge height at pixel column `x` over [x0, x1): zero at x0, kCrossfadeWedgePx at x1 - 1. The +// audible region and the ingredient ghost are the SAME ramp over the two spans the fade mixes, +// so one function draws both. +int crossfadeWedgeHeight(int x0, int x1, int x); + // Which marker (index into the caller's parallel `frames` array, in draw order) a grab at // (x, y) lands on, or -1 for a miss. A marker is grabbed when x is within kMarkerGrabWidth of // its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie. diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 1802e8c..677c636 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -9,7 +9,7 @@ two small identity/helper headers this directory owns outright The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`, `sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`, -`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, +`sample_chrome`, `keyboard_strip`, `waveform_view`, `loop_marks`, `capture_browser`, `browser_scroll`, `param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`, `deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, `limiter`, `meter_ballistics`, `reasampler_uid.h`) lives in `core/instrument/*` and diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index 55180b1..d03f76d 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -86,7 +86,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal sampler_core sample_map component_state_io capture_paths embed_strip app_version capture_browser keyboard_strip sample_bands sample_chrome - waveform_view bank_sync browser_scroll param_slider tooltip + waveform_view loop_marks bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage limiter meter_ballistics bake_hold diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 6d623db..72ee77b 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -142,6 +142,48 @@ instrument::ui::DeckEnableState ReaSamplerEditor::deckEnableState() const { play.filterSpline.mode == EnvMode::Spline}; } +bool ReaSamplerEditor::loopControlsLive() const { + return effectivePlayMode(params_.play) == PlayMode::Gate; +} + +instrument::ui::WaveMarks ReaSamplerEditor::waveMarksFor(const SetupMarkers& m) const { + using instrument::ui::WaveMark; + instrument::ui::WaveMarks w; + w.frame[static_cast(WaveMark::kStart)] = m.start; + w.frame[static_cast(WaveMark::kLoopStart)] = m.loopStart; + w.frame[static_cast(WaveMark::kLoopEnd)] = m.loopEnd; + // The crossfade grows LEFT from the seam it closes, which is where it is audible. + w.frame[static_cast(WaveMark::kCrossfade)] = m.loopEnd - m.crossfade; + // 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. + w.present[static_cast(WaveMark::kStart)] = true; + w.present[static_cast(WaveMark::kLoopStart)] = true; + w.present[static_cast(WaveMark::kLoopEnd)] = true; + w.present[static_cast(WaveMark::kCrossfade)] = m.hasLoop; + return w; +} + +instrument::ui::WaveMarks ReaSamplerEditor::grabbableMarks(const SetupMarkers& m) const { + using instrument::ui::WaveMark; + instrument::ui::WaveMarks w = waveMarksFor(m); + if (!loopControlsLive()) { + w.present[static_cast(WaveMark::kLoopStart)] = false; + w.present[static_cast(WaveMark::kLoopEnd)] = false; + w.present[static_cast(WaveMark::kCrossfade)] = false; + } + return w; +} + +void ReaSamplerEditor::setLoopEnabled(bool on) { + const auto frames = static_cast(monoPcmFor(selectedId_).size()); + if (frames <= 0) return; + SetupMarkers m = pickedMarkers(frames); + if (m.hasLoop == on) return; // a no-op commit would buy a re-decode for nothing + m.hasLoop = on; + applyMarkers(m); + commitAndReload(); +} + void ReaSamplerEditor::applyDeckKnob(int id, double norm) { if (!processor_) return; norm = clamp01(norm); diff --git a/src/shell/instrument/editor_input.cpp b/src/shell/instrument/editor_input.cpp index 56d7b09..e498961 100644 --- a/src/shell/instrument/editor_input.cpp +++ b/src/shell/instrument/editor_input.cpp @@ -170,6 +170,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) { const FaceLayout fl = faceLayout(w, hgt); h = hoverChrome(fl, x, y); if (h.kind == HoverKind::kNone && !selectedId_.empty()) h = hoverDeck(fl, x, y); + if (h.kind == HoverKind::kNone && !selectedId_.empty()) h = hoverWaveform(fl, x, y); } if (h != hover_) { diff --git a/src/shell/instrument/editor_input_chrome.cpp b/src/shell/instrument/editor_input_chrome.cpp index fc9fa1b..8da7c46 100644 --- a/src/shell/instrument/editor_input_chrome.cpp +++ b/src/shell/instrument/editor_input_chrome.cpp @@ -1,6 +1,6 @@ // editor_input_chrome.cpp — the CHROME band's input: the Browse nav, the preview trigger, -// the preview-velocity knob grab, the channel toggle, and the piano strip's root grab plus -// its live drag. Windows-only. +// the preview-velocity knob grab, the loop enable, the channel toggle, and the piano strip's +// root grab plus its live drag. Windows-only. #include "shell/instrument/reasampler_editor.h" @@ -73,6 +73,21 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) { invalidate(); return true; } + // The loop enable. Inert (not hidden) outside Gate: that refusal comes from the engine and + // no click can talk it out of it — unlike the user's own off, which the marks themselves + // still offer to reverse. + if (loopControlsLive()) { + if (contains(cr.loopOff, x, y)) { + setLoopEnabled(false); + invalidate(); + return true; + } + if (contains(cr.loopOn, x, y)) { + setLoopEnabled(true); + invalidate(); + return true; + } + } if (contains(cr.chanMono, x, y)) { channelMode_ = ChannelMode::Mono; processor_->setChannelMode(ChannelMode::Mono); @@ -146,6 +161,10 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1}; if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1}; if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1}; + if (loopControlsLive()) { + if (contains(cr.loopOff, x, y)) return {HoverKind::kLoopOff, -1}; + if (contains(cr.loopOn, x, y)) return {HoverKind::kLoopOn, -1}; + } if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1}; if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1}; if (!cr.rootStrip.empty()) { diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index 01d2673..c3a4c3a 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -55,16 +55,20 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { } const SetupMarkers m = pickedMarkers(frames); + const WaveMarks grabbable = grabbableMarks(m); const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; // Three affordances can claim the same pixel: a node (the staged envelope's or the drawn - // contour's — a small fixed pick box either way), the crossfade tab (a small clipped - // top-strip tab), and a marker's full-height grab column (waveform_view.h's tab-vs-column - // split already keeps the tab apart from ITS OWN column; this is the cross-affordance case - // on top of that). resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures - // each claimant's own NOMINAL target area and lets the smallest hit win, since a fixed check - // order shadows whichever one loses the tie — this seam regressed twice from exactly that - // fix. Never add here (kAdd is only tried once nothing else has claimed the click, below). + // contour's — a small fixed pick box either way), a mark's CAP (a small clipped top-strip + // tab), and a mark's full-height grab column (waveform_view.h's cap-vs-column split already + // keeps a cap apart from ITS OWN column; this is the cross-affordance case on top of that). + // resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures each claimant's + // own NOMINAL target area and lets the smallest hit win, since a fixed check order shadows + // whichever one loses the tie — this seam regressed twice from exactly that fix. Giving + // every mark a cap changed WHICH mark the cap slot resolves to, not the slot's nominal area + // (every cap is one markerHandleRect) and not the ordering cap < node < column, so the + // arbitration itself is unchanged. Never add here (kAdd is only tried once nothing else has + // claimed the click, below). WaveformClaim node; if (envNodeHit.hit) { constexpr std::int64_t side = 2 * kNodeGrabRadius + 1; @@ -81,18 +85,22 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { } } - const Rect tabRect = - m.hasLoop ? markerHandleRect(overlay, frames, m.loopStart - m.crossfade) : Rect{}; - const WaveformClaim tab = (m.hasLoop && contains(tabRect, x, y)) - ? WaveformClaim{true, static_cast(tabRect.width) * - tabRect.height} - : WaveformClaim{}; + const int capHit = capAtPoint(overlay, frames, grabbable, x, y); + const WaveformClaim tab = + (capHit >= 0) + ? WaveformClaim{true, static_cast(2 * kMarkerHandleHalfWidth + 1) * + kMarkerHandleHeight} + : WaveformClaim{}; // Nominal, not actual: markerAtPoint clips the column at the overlay edges (a marker at // frame 0 has 6 usable columns, not 11) and the node's fixed side clips too at a pick-box // corner. Both overestimate in the direction that already produces the intended winner, so // the arbitration runs on NOMINAL area, not the measured hit-testable pixel count. - const int markerHit = markerAtPoint(overlay, frames, markerFrames, 3, x, y); + const int markerHit = + (grabbable.present[static_cast(WaveMark::kLoopStart)] + ? markerAtPoint(overlay, frames, markerFrames, 3, x, y) + // In Trigger only START answers a column, and it is index 0 of the same array. + : markerAtPoint(overlay, frames, markerFrames, 1, x, y)); const WaveformClaim marker = (markerHit >= 0) ? WaveformClaim{true, static_cast(2 * kMarkerGrabWidth + 1) * @@ -114,7 +122,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { } return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false); case WaveformClaimant::kTab: - beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x); + beginMarkerDrag(static_cast(capHit), m, frames, x); return true; case WaveformClaimant::kMarker: beginMarkerDrag(static_cast(markerHit), m, frames, x); @@ -179,6 +187,28 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in return true; } +ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x, + int y) { + // Caps only: the cap is the grip, so it is the one thing on the overlay a resting pointer + // can be "on". A hovered mark promotes its own label past the suppression rule. + // + // Rejected on the band's own rect FIRST, before anything expensive: this runs on every + // WM_MOUSEMOVE, and with no loop override set pickedMarkers costs a bridge read plus a bank + // parse. Every cap lives in the top kMarkerHandleHeight of the band, so that strip is the + // only place the answer can be anything but a miss. + const Rect& band = fl.bands.waveform; + if (band.empty() || x < band.x || x >= band.right() || y < band.y || + y >= band.y + kMarkerHandleHeight) { + return {}; + } + const auto frames = static_cast(monoPcmFor(selectedId_).size()); + if (frames <= 0) return {}; + const OverlayArea overlay = waveformOverlayArea(band); + const int cap = capAtPoint(overlay, frames, grabbableMarks(pickedMarkers(frames)), x, y); + if (cap < 0) return {}; + return {HoverKind::kWaveMark, cap}; +} + void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m, std::int64_t frames, int x) { drag_ = DragKind::kWaveMarker; @@ -243,7 +273,7 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { const int idx = static_cast(waveMarker_); const std::int64_t startVals[4] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, dragStartMarkers_.loopEnd, - dragStartMarkers_.loopStart - + dragStartMarkers_.loopEnd - dragStartMarkers_.crossfade}; std::int64_t newFrame = resolveDragFrame(overlay, frames, startVals[idx], dx); @@ -251,13 +281,16 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { // 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 that its edges need no zero crossing. const std::vector& pcm = monoPcmFor(selectedId_); - if (!pcm.empty() && waveMarker_ != WaveMarker::kLoopXfade) { + if (!pcm.empty() && waveMarker_ != WaveMarker::kCrossfade) { newFrame = nearestZeroCrossing(pcm.data(), static_cast(pcm.size()), newFrame); } // Build the edited marker set from the snapshot, moving only the grabbed marker, then - // clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop. + // clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a LOOP marker turns the + // enable on — a grab implies intent to loop, and it is what teaches the chrome toggle by + // demonstration. Dragging START does not: it is live in both modes and says nothing about + // the loop. SetupMarkers m = dragStartMarkers_; if (waveMarker_ == WaveMarker::kStart) { m.start = newFrame; @@ -267,8 +300,8 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { } else if (waveMarker_ == WaveMarker::kLoopEnd) { m.loopEnd = (std::max)(newFrame, m.loopStart); m.hasLoop = true; - } else { // kLoopXfade — the handle sits at loopStart - crossfade, so left lengthens it - m.crossfade = (std::max)(std::int64_t{0}, m.loopStart - newFrame); + } else { // kCrossfade — the handle sits at loopEnd - crossfade, so left still lengthens it + m.crossfade = (std::max)(std::int64_t{0}, m.loopEnd - newFrame); } if (m.start < 0) m.start = 0; if (m.start > frames - 1) m.start = frames - 1; diff --git a/src/shell/instrument/editor_paint.cpp b/src/shell/instrument/editor_paint.cpp index 2595326..f864215 100644 --- a/src/shell/instrument/editor_paint.cpp +++ b/src/shell/instrument/editor_paint.cpp @@ -55,7 +55,14 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { const FaceLayout fl = faceLayout(w, h); const bool empty = selectedId_.empty(); - paintChrome(bmp, fl, empty); + // Resolved ONCE per paint and handed to both bands that show it. The chrome enable and the + // waveform marks are two views of the same `hasLoop`, so they must not resolve it + // separately — and with no loop override set the resolve costs a bridge read plus a bank + // parse, which is not a cost to pay twice a frame. + SetupMarkers marks; + if (!empty) marks = pickedMarkers(static_cast(monoPcmFor(selectedId_).size())); + + paintChrome(bmp, fl, empty, marks); // Nothing loaded: the lower bands carry the "pick a capture" prompt pointing at Browse // (which the chrome lit above), and there is nothing to deck. @@ -66,7 +73,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { return; } - paintWaveform(bmp, fl.bands.waveform); + paintWaveform(bmp, fl.bands.waveform, marks); paintDeck(bmp, fl); // The curve popup: a centered sheet over the whole face, drawn last. diff --git a/src/shell/instrument/editor_paint_chrome.cpp b/src/shell/instrument/editor_paint_chrome.cpp index 0abb6ba..b5270e9 100644 --- a/src/shell/instrument/editor_paint_chrome.cpp +++ b/src/shell/instrument/editor_paint_chrome.cpp @@ -1,7 +1,7 @@ // editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title + -// live readout, then the control run — preview, preview-velocity knob, Mono|Stereo, Browse) -// over the strip row, which the piano strip has to itself. Windows-only; all rects come from -// the pure sample_chrome interior and the pure keyboard_strip geometry. +// live readout, then the control run — preview, preview-velocity knob, Loop Off|On, +// Mono|Stereo, Browse) over the strip row, which the piano strip has to itself. Windows-only; +// all rects come from the pure sample_chrome interior and the pure keyboard_strip geometry. #include "shell/instrument/reasampler_editor.h" @@ -94,7 +94,8 @@ void drawRootKey(LICE_IBitmap* bmp, const Rect& area, const StripLayout& sl, int } // namespace -void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty) { +void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty, + const SetupMarkers& marks) { const ChromeRects& cr = fl.chrome; fillSurface(bmp, toKitBox(cr.toolbar), Role::BgPanel, InteractionState::Rest); @@ -211,6 +212,29 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool } } + // Loop Off | On. A two-segment toggle in the same primitive as Mono|Stereo because it is + // the same class of control: a playback mode of the loaded capture. Outside Gate both + // segments draw Disabled and neither accepts a click — the state is preserved, not cleared, + // so the return to Gate restores it. + { + const bool live = loopControlsLive(); + const bool on = marks.hasLoop; + const auto segState = [&](bool active, HoverKind hk) { + if (!live) return InteractionState::Disabled; + if (active) return InteractionState::Active; + return isHovered(hk, -1) ? InteractionState::Hover : InteractionState::Rest; + }; + const InteractionState offState = segState(!on, HoverKind::kLoopOff); + const InteractionState onState = segState(on, HoverKind::kLoopOn); + fillSurface(bmp, toKitBox(cr.loopOff), Role::BgCell, offState); + fillSurface(bmp, toKitBox(cr.loopOn), Role::BgCell, onState); + const Role dim = live ? Role::TextPrimary : Role::TextDim; + kitTextCentered(bmp, cr.loopOff, "Loop Off", kToolbarFont, + (live && !on) ? Role::BgBase : dim); + kitTextCentered(bmp, cr.loopOn, "Loop On", kToolbarFont, + (live && on) ? Role::BgBase : dim); + } + // Mono | Stereo output-mode toggle. { const bool isStereo = (channelMode_ == ChannelMode::Stereo); diff --git a/src/shell/instrument/editor_paint_waveform.cpp b/src/shell/instrument/editor_paint_waveform.cpp index 9e62054..c398fb7 100644 --- a/src/shell/instrument/editor_paint_waveform.cpp +++ b/src/shell/instrument/editor_paint_waveform.cpp @@ -1,5 +1,6 @@ -// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop -// span + start/loop markers, and the amp-envelope overlay. Windows-only. +// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop span, +// the four marks (line + shaped cap + label) with the crossfade's wedge and ghost, and the +// envelope overlay. Windows-only. All cap/label geometry is the pure waveform_view module's. // // Overlay contract: see waveform_view.h's WaveformSurface. @@ -26,16 +27,41 @@ using namespace reasampler::instrument::ui; // lanes + waveform geometry using audio::computeEnvelope; namespace { -// Marker roles — semantic, drawn through the kit's palette: start AND loop start/end both -// = teal (secondary). Markers are 2px bars and a translucent span fill, not the 1px trace, so -// they live with 1.92:1 against the waveform; the trace, which cannot, has its own role. -// Do not collapse the two back onto one role — they overlap in this rect. The trace crossing -// the loop-span fill is a KNOWN, ACCEPTED under-floor pair (2.25:1 against a 3:1 floor), and no -// trace value fixes it — see the two-neighbour rule in core/ui/CLAUDE.md. If it is ever -// resolved, the FILL is what changes; do not nudge a color to chase it. -constexpr Role kRoleStartMarker = Role::AccentSecondary; +// Marker roles — semantic, drawn through the kit's palette. The loop family is teal +// (secondary); markers are 2px bars and a translucent span fill, not the 1px trace, so they live +// with 1.92:1 against the waveform. Do not collapse the two back onto one role — they overlap in +// this rect. The trace crossing the loop-span fill is a KNOWN, ACCEPTED under-floor pair (2.25:1 +// against a 3:1 floor), and no trace value fixes it — see the two-neighbour rule in +// core/ui/CLAUDE.md. If it is ever resolved, the FILL is what changes; do not nudge a color to +// chase it. +// +// START is deliberately NOT accent/primary, which is what the design called for: accent/primary +// IS the waveform's own fill, so a primary START mark would be 1:1 against the material it marks +// — worse than the teal it replaced, not better. overlay/trace is the one role that clears 3:1 +// against BOTH the lime and bg/base (core/ui/CLAUDE.md's two-neighbour ceiling sits exactly on +// it), so it is the only ink that can carry a distinct always-in-effect mark here. It reads +// apart from the envelope trace by shape: a straight full-height column under a solid triangle +// cap, never a curve. +constexpr Role kRoleStartMarker = Role::OverlayTrace; constexpr Role kRoleLoopMarker = Role::AccentSecondary; +// Mark weights. A Disabled mark (loop off, or Trigger) keeps its position and its cap so the +// 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 kMarkAlphaXfade = 0.7f; +constexpr float kMarkAlphaDisabled = 0.4f; + +// The dashed crossfade line: a 3 px stroke every 6 px down the band. +constexpr int kDashOn = 3; +constexpr int kDashPeriod = 6; + +// The ingredient ghost's weight relative to the audible wedge, and the hairline it draws at +// rest. It fills in only while the crossfade handle is hovered or dragged — the relationship is +// revealed when the user asks about it, not permanently. +constexpr float kGhostAlpha = 0.5f; +constexpr int kGhostHairlinePx = 1; + // Envelope-handle half-extents. Grabbed grows and hollows out; kNodeGrabRadius (envelope_edit) // is the PICK radius and is unrelated — a handle may draw larger than it without widening any // hit region. @@ -46,9 +72,91 @@ constexpr int kEnvHandleRingPx = 2; // Both envelope traces — staged and drawn — are one grammar and one weight. Two pixels is what // reads as a trace rather than a hairline over the waveform behind it. constexpr float kEnvTracePx = 2.0f; + +const char* markLabel(WaveMark m) { + switch (m) { + case WaveMark::kStart: return "START"; + case WaveMark::kLoopStart: return "LOOP"; + case WaveMark::kLoopEnd: return "END"; + case WaveMark::kCrossfade: return "XFADE"; + case WaveMark::kCount: break; + } + return ""; +} + +// Font::Micro is proportional, so this is a generous per-character estimate: the label box may +// end up wider than the glyphs, never narrower — an under-estimate would let the suppression +// rule place two boxes that visibly collide. +constexpr int kMicroCharPx = 6; +int markLabelWidth(WaveMark m) { + int n = 0; + for (const char* s = markLabel(m); *s; ++s) ++n; + return n * kMicroCharPx; +} + +// One mark's cap glyph, drawn inside the cap rect the hit-test uses. The four shapes ARE the +// marks' identities — a label may be suppressed, a cap never is. +void drawMarkCap(LICE_IBitmap* bmp, WaveMark which, const Rect& cap, int mx, LICE_pixel ink, + float alpha) { + if (cap.empty()) return; + const int top = cap.y; + const int bot = cap.bottom(); + const int arm = kMarkerHandleHalfWidth; // the cap's own half-width, so glyph == grip + switch (which) { + case WaveMark::kStart: + // A play flag: it points into the material that will play. + LICE_FillTriangle(bmp, mx - 1, top, mx - 1, bot, mx - 1 + arm + 2, (top + bot) / 2, + ink, alpha, 0); + break; + case WaveMark::kLoopStart: + // '[' — opens right, into the span. + LICE_FillRect(bmp, mx - 1, top, 2, cap.height, ink, alpha, 0); + LICE_FillRect(bmp, mx - 1, top, arm + 1, 2, ink, alpha, 0); + LICE_FillRect(bmp, mx - 1, bot - 2, arm + 1, 2, ink, alpha, 0); + break; + case WaveMark::kLoopEnd: + // ']' — opens left, into the span. The opposed pair reads as an enclosure. + LICE_FillRect(bmp, mx - 1, top, 2, cap.height, ink, alpha, 0); + LICE_FillRect(bmp, mx - arm, top, arm + 1, 2, ink, alpha, 0); + LICE_FillRect(bmp, mx - arm, bot - 2, arm + 1, 2, ink, alpha, 0); + break; + case WaveMark::kCrossfade: + // A ramp whose hypotenuse rises toward the seam — the fade's own shape. + LICE_FillTriangle(bmp, mx - 1, bot - 1, mx - 1 + arm, bot - 1, mx - 1 + arm, top, + ink, alpha, 0); + break; + case WaveMark::kCount: + break; + } +} + +// The crossfade region over [f0, f1) as a top-and-bottom edge wedge. NEVER a fill: the audible +// region sits INSIDE the loop span, and a translucent fill there would stack on the loop fill, +// making the already-accepted 2.25:1 trace pair worse. `filled` false draws the resting ghost — +// a hairline dashed outline of the same wedge. +void drawCrossfadeWedge(LICE_IBitmap* bmp, const OverlayArea& overlay, std::int64_t frames, + std::int64_t f0, std::int64_t f1, LICE_pixel ink, float alpha, + bool filled) { + const Rect& r = overlay.rect; + const int x0 = frameToX(overlay, frames, f0); + const int x1 = frameToX(overlay, frames, f1); + if (x1 <= x0 || r.empty()) return; + for (int x = x0; x < x1; ++x) { + const int h = crossfadeWedgeHeight(x0, x1, x); + if (h <= 0) continue; + if (filled) { + LICE_FillRect(bmp, x, r.y, 1, h, ink, alpha, 0); + LICE_FillRect(bmp, x, r.bottom() - h, 1, h, ink, alpha, 0); + } else if ((x - x0) % kDashPeriod < kDashOn) { + LICE_FillRect(bmp, x, r.y + h - kGhostHairlinePx, 1, kGhostHairlinePx, ink, alpha, 0); + LICE_FillRect(bmp, x, r.bottom() - h, 1, kGhostHairlinePx, ink, alpha, 0); + } + } +} } // namespace -void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) { +void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band, + const SetupMarkers& m) { fillSurface(bmp, toKitBox(band), Role::BgBase, InteractionState::Rest); if (band.empty()) return; @@ -94,47 +202,92 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) { // stereo view reads one loop region rather than two. const OverlayArea& overlay = surface.overlay; const Rect& overlayRect = overlay.rect; - const SetupMarkers m = pickedMarkers(frames); - if (m.hasLoop && m.loopEnd > m.loopStart) { - const int lx = frameToX(overlay, frames, m.loopStart); - const int rx = frameToX(overlay, frames, m.loopEnd); - if (rx > lx) { - LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height, - toLice(roleColor(kRoleLoopMarker)), - static_cast(kLoopSpanFillAlpha), 0); - } + const bool loopLive = loopControlsLive(); + const bool loopOn = m.hasLoop && loopLive; + const WaveMarks marks = waveMarksFor(m); + const LICE_pixel loopInk = toLice(roleColor(kRoleLoopMarker)); + + const int lx = frameToX(overlay, frames, m.loopStart); + const int rx = frameToX(overlay, frames, m.loopEnd); + if (loopOn && rx > lx) { + LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height, loopInk, + static_cast(kLoopSpanFillAlpha), 0); } - // The crossfade region, at half the loop span's weight so the two read as nested rather - // than as a second loop. Drawn before the marker bars so the bars stay on top. - if (m.hasLoop && m.crossfade > 0) { - const int fx = frameToX(overlay, frames, m.loopStart - m.crossfade); - const int lx = frameToX(overlay, frames, m.loopStart); - if (lx > fx) { - LICE_FillRect(bmp, fx, overlayRect.y, lx - fx, overlayRect.height, - toLice(roleColor(kRoleLoopMarker)), - static_cast(kLoopSpanFillAlpha) * 0.5f, 0); - } + + // The crossfade, in the two places it exists: the AUDIBLE region, over the frames the fade + // actually runs on, and its INGREDIENT — the material one loop length earlier that is being + // mixed in — as a ghost. Drawing only the ingredient (which is what shipped before) put the + // one grab affordance on the wrong side of the loop from the sound it controls. + const int xfadeIdx = static_cast(WaveMark::kCrossfade); + const bool xfadeHot = + (drag_ == DragKind::kWaveMarker && waveMarker_ == WaveMark::kCrossfade) || + isHovered(HoverKind::kWaveMark, xfadeIdx); + if (loopOn && m.crossfade > 0) { + drawCrossfadeWedge(bmp, overlay, frames, m.loopEnd - m.crossfade, m.loopEnd, loopInk, + kMarkAlpha, /*filled=*/true); + // The clamp is crossfade <= min(loopStart, loopLength), and each half is now visible: + // the ghost's left edge reaches frame 0 exactly at the loopStart bound, and the audible + // wedge's left edge reaches the LOOP mark exactly at the loopLength bound. The user + // sees why the fade stopped growing instead of hitting an invisible wall. + drawCrossfadeWedge(bmp, overlay, frames, m.loopStart - m.crossfade, m.loopStart, loopInk, + kGhostAlpha, /*filled=*/xfadeHot); } - const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; - const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker}; - for (int i = 0; i < 3; ++i) { - const int mx = frameToX(overlay, frames, markerFrames[i]); - const bool loopMarker = (i != 0); - const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; - LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height, - toLice(roleColor(markerRoles[i])), alpha, 0); + + // 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. + const char* caption = nullptr; + if (!loopLive) caption = "LOOP - GATE ONLY"; + else if (!m.hasLoop) caption = m.parked ? "DRAG TO SET LOOP" : "LOOP OFF"; + if (caption != nullptr && rx > lx) { + kitTextCentered(bmp, Rect::ltrb(lx, overlayRect.y, rx, overlayRect.bottom()), caption, + Font::Micro, Role::TextDim); } - // The crossfade's grab tab. Only offered with a loop set, matching the hit-test, and it - // is the whole affordance for a zero-length fade — nothing else marks where it sits. - if (m.hasLoop) { - const Rect tab = markerHandleRect(overlay, frames, m.loopStart - m.crossfade); - if (!tab.empty()) { - LICE_FillRect(bmp, tab.x, tab.y, tab.width, tab.height, - toLice(roleColor(kRoleLoopMarker)), 1.0f, 0); + + // Labels beneath the trace and the handles in z-order; the promoted one is re-drawn ON TOP + // after the overlay, so you always see what you grabbed. + const int promoted = + (drag_ == DragKind::kWaveMarker) + ? static_cast(waveMarker_) + : (hover_.kind == HoverKind::kWaveMark ? hover_.index : -1); + int labelW[kWaveMarkCount]; + for (int i = 0; i < kWaveMarkCount; ++i) labelW[i] = markLabelWidth(static_cast(i)); + const WaveMarkLabels labels = layoutMarkLabels(overlay, frames, marks, labelW, promoted); + for (int i = 0; i < kWaveMarkCount; ++i) { + if (i == promoted || labels.box[i].empty()) continue; + kitTextCentered(bmp, labels.box[i], markLabel(static_cast(i)), Font::Micro, + Role::TextDim); + } + + // Line + shaped cap per mark, one grammar. A mark whose gesture is refused draws Disabled + // rather than hidden — the position is information the user put there. + for (int i = 0; i < kWaveMarkCount; ++i) { + if (!marks.present[i]) continue; + const WaveMark which = static_cast(i); + const bool isStart = (which == WaveMark::kStart); + const bool dim = !isStart && !loopOn; + const LICE_pixel ink = isStart ? toLice(roleColor(kRoleStartMarker)) : loopInk; + const float alpha = dim ? kMarkAlphaDisabled + : (which == WaveMark::kCrossfade ? kMarkAlphaXfade : kMarkAlpha); + const int mx = frameToX(overlay, frames, marks.frame[i]); + if (which == WaveMark::kCrossfade) { + // Dashed: a soft boundary, not a hard one. + for (int y = overlayRect.y; y < overlayRect.bottom(); y += kDashPeriod) { + const int h = (std::min)(kDashOn, overlayRect.bottom() - y); + LICE_FillRect(bmp, mx - 1, y, 2, h, ink, alpha, 0); + } + } else { + LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height, ink, alpha, 0); } + drawMarkCap(bmp, which, markerHandleRect(overlay, frames, marks.frame[i]), mx, ink, + alpha); } paintEnvelopeOverlay(bmp, overlay, frames); + + if (promoted >= 0 && promoted < kWaveMarkCount && !labels.box[promoted].empty()) { + kitTextCentered(bmp, labels.box[promoted], markLabel(static_cast(promoted)), + Font::Micro, Role::TextPrimary); + } } void ReaSamplerEditor::paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea) { diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 7c51cad..29179b2 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -18,7 +18,6 @@ #include "core/util/file_bytes.h" // shared whole-file loader #include "ext_keys.h" #include "core/instrument/bake/bake_plan.h" // bakeWindowNeedsHold (the Hold predicate) -#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span) #include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter) #include "shell/instrument/instrument_bake.h" // the deferred bake the sync tick runs #include "shell/instrument/reaper_bridge.h" @@ -34,8 +33,6 @@ using capture::WavLayout; using capture::extractFloatFrames; using capture::parseWavLayout; using capture::resolveBankFile; -using instrument::engine::loop::LoopBounds; -using instrument::engine::loop::defaultLoopBounds; using instrument::ui::nameMatchesQuery; using ui::ThumbnailKey; using ui::thumbnailKeyString; @@ -218,15 +215,16 @@ void ReaSamplerEditor::loadSelection(const std::string& id) { } ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { - SetupMarkers m; - // Seed from the bank's intrinsic loop (fact about the file), then let the parameter set's - // override win (the instrument's performance choice). Read the loop intrinsic from the - // live bank blob (the same path selectSample uses); when that is not readable (extension - // absent / not yet parsed) the instance-owned ref carries the same intrinsics. Skipped - // entirely once an override is already set — it would just be overwritten below, and the - // bridge read + JSON parse it costs is real (mouseDownWaveform's arbitration calls this on - // every waveform click, not just marker grabs, to know whether a tab or marker candidate - // hits at all). + instrument::ui::StoredLoop stored; + stored.override_ = params_.loopOverride; + stored.crossfade = params_.loopCrossfadeFrames; + stored.startPoint = params_.startPoint; + // Read the loop intrinsic from the live bank blob (the same path selectSample uses); when + // that is not readable (extension absent / not yet parsed) the instance-owned ref carries + // the same intrinsics. Skipped entirely once an override is already set — the resolve would + // discard it, and the bridge read + JSON parse it costs is real (mouseDownWaveform's + // arbitration calls this on every waveform click, not just marker grabs, to know whether a + // cap or column candidate hits at all). if (processor_ && !params_.loopOverride) { std::optional sel; auto banksJson = @@ -236,31 +234,9 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram const SampleRefs refs = processor_->sampleRefs(); if (const SelectedSample* r = findRef(refs, selectedId_)) sel = *r; } - if (sel && sel->loop.hasLoop) { - m.hasLoop = true; - m.loopStart = sel->loop.start; - m.loopEnd = sel->loop.end; - } + if (sel) stored.intrinsic = sel->loop; } - // The parameter set's override (loop + start) supersedes the intrinsic. - if (params_.loopOverride) { - m.hasLoop = params_.loopOverride->hasLoop; - m.loopStart = params_.loopOverride->start; - m.loopEnd = params_.loopOverride->end; - } - if (params_.startPoint) m.start = *params_.startPoint; - m.crossfade = params_.loopCrossfadeFrames; - // A collapsed or inverted span is the OFF state (the engine refuses it either way), so - // park the handles on the shared default rather than leaving them stacked on each other - // where neither could be grabbed apart again. The markers are still drawn at 'no loop' - // weight — drag one to CREATE a loop. - if (!m.hasLoop || m.loopEnd <= m.loopStart) { - m.hasLoop = false; - const LoopBounds d = defaultLoopBounds(frames); - m.loopStart = d.start; - m.loopEnd = d.end; - } - return m; + return instrument::ui::resolveLoopMarks(stored, frames); } bool ReaSamplerEditor::HoldNeedKey::operator==(const HoldNeedKey& o) const { @@ -303,20 +279,13 @@ bool ReaSamplerEditor::resolveBakeHoldNeeded() { } void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) { - // Write the edited markers into the parameter set as the loop/start override. The bank - // intrinsic is never written (read-only bank consumer). - SampleLoop loop; - // Collapsing the span onto itself is the OFF gesture — record it as such so the next - // pickedMarkers re-offers the default handles instead of two coincident ones. - loop.hasLoop = m.hasLoop && m.loopEnd > m.loopStart; - loop.start = m.loopStart; - loop.end = m.loopEnd; - params_.loopOverride = loop; - // OFF parks the crossfade at 0 too — loadSelection's own clear (a fresh capture has no - // loop to fade) is the same rule; leaving a stale length here would silently re-apply it - // (clamped) the next time a loop is dragged back in. - params_.loopCrossfadeFrames = loop.hasLoop ? m.crossfade : 0; - params_.startPoint = m.start; + // Write the edited markers into the parameter set as the loop/start override; the fold + // itself is the pure loop_marks module's. The bank intrinsic is never written (read-only + // bank consumer). + const instrument::ui::LoopWrite w = instrument::ui::applyLoopMarks(m); + params_.loopOverride = w.loop; + params_.loopCrossfadeFrames = w.crossfade; + params_.startPoint = w.start; } int ReaSamplerEditor::effectiveRoot() const { diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index df050eb..a9cee85 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -19,7 +19,9 @@ #include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit) #include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam) #include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band) +#include "core/instrument/ui/loop_marks.h" // LoopMarks (the loop enable's state machine) #include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator) +#include "core/instrument/ui/waveform_view.h" // WaveMark / WaveMarks (the overlay's marks) #include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar #include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior) #include "core/audio/peaks.h" // Envelope (the cached peak thumbnail) @@ -104,12 +106,13 @@ private: // [0, DeckParam::kCount) is what keeps liveCommitFor answering "not a live control". static constexpr int kBakeHoldKnobId = -3; - // The waveform markers on the waveform band: start-point + the sustain loop's two ends, - // in draw + hit order, then the crossfade handle. The crossfade is NOT part of the - // full-height column hit-test — it answers only in its top-strip handle (waveform_view's + // The waveform band's four marks, in draw + hit order. The crossfade is NOT part of the + // full-height column hit-test — it answers only in its cap (waveform_view's // markerHandleRect), because at a zero fade it sits exactly on the loop start. - enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kLoopXfade = 3, - kCount = 4 }; + using WaveMarker = instrument::ui::WaveMark; + + // What those four marks are showing — see pickedMarkers. + using SetupMarkers = instrument::ui::LoopMarks; // The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index` // disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1 when @@ -125,6 +128,9 @@ private: kBrowseCancel, // the Browse modal "Cancel" button kChanMono, // the mono channel-mode segment kChanStereo, // the stereo channel-mode segment + kLoopOff, // the loop enable's Off segment + kLoopOn, // the loop enable's On segment + kWaveMark, // a waveform overlay mark (index = WaveMark ordinal); promotes its label kPreview, // the preview-trigger button kBake, // the resample-bake trigger kControl, // a knob-deck element (index = control id) @@ -162,12 +168,14 @@ private: // --- Band painters (one TU each, mirroring the input side) --- // Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob, // channel toggle). - void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty); + void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty, + const SetupMarkers& marks); // The hovered piano key's note-name chip. Drawn after every band — it overhangs the // chrome into whatever is below it. void paintChromeTooltip(LICE_IBitmap* bmp, const FaceLayout& fl, int w, int h); - // Waveform: the channel lane(s), the loop/start markers, and the envelope overlay. - void paintWaveform(LICE_IBitmap* bmp, const Rect& band); + // Waveform: the channel lane(s), the four marks, and the envelope overlay. `marks` is + // resolved once per paint by paintSample — see there. + void paintWaveform(LICE_IBitmap* bmp, const Rect& band, const SetupMarkers& marks); // Decks: the group fence + caption + compact caption toggles + radial knobs with // label<->value swap on hover/drag. void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl); @@ -243,6 +251,9 @@ private: // mouse-down branches but are read-only. Windows-only. void resolveHover(int x, int y); HoverTarget hoverChrome(const FaceLayout& fl, int x, int y) const; + // Marks only — the cap strip. Everything else in the band already reports its own state + // through the drag, so nothing else on the overlay needs a hover. + HoverTarget hoverWaveform(const FaceLayout& fl, int x, int y); HoverTarget hoverDeck(const FaceLayout& fl, int x, int y) const; HoverTarget hoverBrowse(int w, int h, int x, int y) const; HoverTarget hoverCurvePopup(int w, int h, int x, int y) const; @@ -326,20 +337,27 @@ private: // instance's own SampleRefs as the self-contained fallback. "" when unresolvable. std::string samplePathFor(const std::string& sampleId) const; - // The effective loop + start markers for the loaded capture: the parameter set's - // override when one is set, else the bank's loop intrinsic / frame 0. With no loop set, - // the loop handles park on loop_span's defaultLoopBounds so both stay grabbable — the - // frame-0 default they replace put loopStart under the start marker, where nothing could - // reach it. - struct SetupMarkers { - std::int64_t start = 0; - std::int64_t loopStart = 0; - std::int64_t loopEnd = 0; - std::int64_t crossfade = 0; // pre-seam fade, SOURCE frames; handle at loopStart - this - bool hasLoop = false; // whether a sustain loop is set (drives the "no loop" affordance) - }; + // The effective loop + start markers for the loaded capture. The state machine behind them + // — which stored source wins, when the pair re-parks, what the two OFF states mean — is the + // pure loop_marks module's; this only reads the bank intrinsic it cannot see. SetupMarkers pickedMarkers(std::int64_t frames) const; + // Whether the loop controls answer at all: the sustain loop is Gate-only, so in Trigger the + // marks and the chrome enable draw Disabled and inert. Reads the mode AFTER the drawn-EG + // fold, so a drawn envelope disables them through the same predicate. + bool loopControlsLive() const; + + // Which marks the band DRAWS, and which of those accept a grab. They differ in exactly one + // place — Trigger, where the loop marks stay drawn (hiding a set loop on a mode flip would + // destroy information the user put there) but refuse every gesture, because that refusal + // comes from the engine and no drag can talk it out of it. + instrument::ui::WaveMarks waveMarksFor(const SetupMarkers& m) const; + instrument::ui::WaveMarks grabbableMarks(const SetupMarkers& m) const; + + // Flips the enable. `on` false retains the span and the crossfade — that retention is the + // whole difference between a toggle and a delete button. + void setLoopEnabled(bool on); + // Whether the loaded sound's bake window needs the user's Hold — the pure predicate // (bake_plan.h) answered against the markers this face is showing. Decodes and reads the // bank, so it is called on the sync tick, not per paint, and memoized against the inputs diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 276734c..198aab0 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -1089,6 +1089,27 @@ static void testLoopSpanAndCrossfadeRoundTrip() { CHECK(out.params.rootOverride && *out.params.rootOverride == 55); } +// The loop ENABLE is `hasLoop`, and the block writes start/end unconditionally — so the wire +// already carries "off, with a span remembered." Nothing about the format changes to make the +// enable user-owned; this pins that the off state and its retained span both survive a reload, +// because an off that came back as a re-parked default would be a delete button, not a toggle. +static void testAnOffLoopRoundTripsWithItsSpanAndCrossfadeRetained() { + ComponentState in; + in.selectionId = "pad"; + SampleLoop lp; + lp.hasLoop = false; + lp.start = 4096; + lp.end = 65536; + in.params.loopOverride = lp; + in.params.loopCrossfadeFrames = 1024; + + const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); + CHECK(out.params.loopOverride && !out.params.loopOverride->hasLoop); + CHECK(out.params.loopOverride && out.params.loopOverride->start == 4096); + CHECK(out.params.loopOverride && out.params.loopOverride->end == 65536); + CHECK(out.params.loopCrossfadeFrames == 1024); +} + // A negative fade cannot mean anything and would only reach resolveLoop's clamp; refusing it // at the wire keeps the parameter set the editor reads back sane. static void testNegativeCrossfadeOnTheWireLiftsToZero() { @@ -2039,6 +2060,7 @@ int main() { testDefaultStateRoundTripsToDefaults(); testEnvelopePrefixBytesFrozen(); testLoopSpanAndCrossfadeRoundTrip(); + testAnOffLoopRoundTripsWithItsSpanAndCrossfadeRetained(); testNegativeCrossfadeOnTheWireLiftsToZero(); testPriorPayloadVersionsLiftToAHardSeam(); testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact(); diff --git a/tests/test_loop_marks.cpp b/tests/test_loop_marks.cpp new file mode 100644 index 0000000..9d37b20 --- /dev/null +++ b/tests/test_loop_marks.cpp @@ -0,0 +1,208 @@ +// Standalone tests for reasampler::instrument::ui::loop_marks — no VST3, no REAPER, no +// framework. Asserts the loop enable's whole state machine: the resolve's park rule and its two +// OFF states, the write's collapse fold and crossfade retention, and the four gestures that +// reach `hasLoop` composed end to end (resolve -> edit -> apply -> resolve), which is exactly +// how the editor drives it. + +#include "../src/core/instrument/ui/loop_marks.h" +#include "../src/core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the park target) + +#include + +using namespace reasampler::instrument::ui; +using reasampler::SampleLoop; +using reasampler::instrument::engine::loop::LoopBounds; +using reasampler::instrument::engine::loop::defaultLoopBounds; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static constexpr std::int64_t kFrames = 10000; + +// The editor's own round trip: what the band shows after a marker set is written back. +static LoopMarks writeThenRead(const LoopMarks& edited, std::int64_t frames = kFrames) { + const LoopWrite w = applyLoopMarks(edited); + StoredLoop s; + s.override_ = w.loop; + s.crossfade = w.crossfade; + s.startPoint = w.start; + return resolveLoopMarks(s, frames); +} + +static StoredLoop storedSpan(bool on, std::int64_t start, std::int64_t end, + std::int64_t crossfade) { + StoredLoop s; + s.override_ = SampleLoop{on, start, end}; + s.crossfade = crossfade; + return s; +} + +// --- resolve: the two OFF states ----------------------------------------------- + +static void testNothingSetParksOnTheDefaultBoundsAndReadsAsNeverSet() { + const LoopMarks m = resolveLoopMarks(StoredLoop{}, kFrames); + const LoopBounds d = defaultLoopBounds(kFrames); + CHECK(!m.hasLoop); + CHECK(m.parked); // the "DRAG TO SET LOOP" state + CHECK(m.loopStart == d.start && m.loopEnd == d.end); +} + +static void testAValidSpanSwitchedOffKeepsItsOwnPositions() { + const LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames); + CHECK(!m.hasLoop); + CHECK(!m.parked); // the "LOOP OFF" state — there is nothing to "set" + CHECK(m.loopStart == 4000 && m.loopEnd == 6000); + CHECK(m.crossfade == 300); + // And it is NOT the park position, which is what the off->on restore depends on. + const LoopBounds d = defaultLoopBounds(kFrames); + CHECK(!(m.loopStart == d.start && m.loopEnd == d.end)); +} + +static void testAnUnusableSpanParksWhateverTheEnableSays() { + const LoopBounds d = defaultLoopBounds(kFrames); + // Collapsed, inverted, past the PCM, and negative — the four the engine refuses. + const StoredLoop bad[4] = {storedSpan(true, 500, 500, 0), storedSpan(true, 900, 400, 0), + storedSpan(true, 500, kFrames + 1, 0), + storedSpan(true, -5, 400, 0)}; + for (const StoredLoop& s : bad) { + const LoopMarks m = resolveLoopMarks(s, kFrames); + CHECK(!m.hasLoop); + CHECK(m.parked); + CHECK(m.loopStart == d.start && m.loopEnd == d.end); + } +} + +static void testTheOverrideSupersedesTheBankIntrinsic() { + StoredLoop s; + s.intrinsic = SampleLoop{true, 100, 200}; + s.override_ = SampleLoop{true, 4000, 6000}; + const LoopMarks m = resolveLoopMarks(s, kFrames); + CHECK(m.hasLoop && m.loopStart == 4000 && m.loopEnd == 6000); + // With no override the intrinsic is what the band shows. + s.override_.reset(); + const LoopMarks i = resolveLoopMarks(s, kFrames); + CHECK(i.hasLoop && i.loopStart == 100 && i.loopEnd == 200); + // An intrinsic that itself says "no loop" is not a span to adopt. + s.intrinsic = SampleLoop{false, 100, 200}; + CHECK(resolveLoopMarks(s, kFrames).parked); +} + +// --- the four gestures onto hasLoop -------------------------------------------- + +// Gesture 1: the enable clicked ON. Span and crossfade retained as-is. +static void testEnableOnKeepsTheSpanAndCrossfade() { + LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames); + m.hasLoop = true; + const LoopMarks after = writeThenRead(m); + CHECK(after.hasLoop); + CHECK(after.loopStart == 4000 && after.loopEnd == 6000); + CHECK(after.crossfade == 300); +} + +// Gesture 2: the enable clicked OFF. Span and crossfade retained — the whole point of it being +// a toggle rather than a delete button. +static void testEnableOffRetainsTheSpanAndCrossfade() { + LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames); + m.hasLoop = false; + const LoopMarks after = writeThenRead(m); + CHECK(!after.hasLoop); + CHECK(!after.parked); + CHECK(after.loopStart == 4000 && after.loopEnd == 6000); + CHECK(after.crossfade == 300); +} + +// The acceptance criterion in one assertion: off then on restores the loop EXACTLY. +static void testOffThenOnRestoresTheLoopExactly() { + const LoopMarks before = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames); + LoopMarks off = before; + off.hasLoop = false; + LoopMarks mid = writeThenRead(off); + mid.hasLoop = true; + const LoopMarks back = writeThenRead(mid); + CHECK(back.hasLoop == before.hasLoop); + CHECK(back.loopStart == before.loopStart && back.loopEnd == before.loopEnd); + CHECK(back.crossfade == before.crossfade); + CHECK(back.parked == before.parked); +} + +// Gesture 3: the span collapsed onto itself. OFF, span destroyed and re-parked, crossfade zeroed. +static void testCollapsingTheSpanTurnsItOffReparksAndZeroesTheCrossfade() { + LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames); + m.loopEnd = m.loopStart; // the drag that lands one mark on the other + const LoopWrite w = applyLoopMarks(m); + CHECK(!w.loop.hasLoop); + CHECK(w.crossfade == 0); + const LoopMarks after = writeThenRead(m); + const LoopBounds d = defaultLoopBounds(kFrames); + CHECK(!after.hasLoop && after.parked); + CHECK(after.loopStart == d.start && after.loopEnd == d.end); + CHECK(after.crossfade == 0); +} + +// Gesture 4: dragging a loop mark while OFF turns it on, in BOTH off-states. +static void testDraggingALoopMarkWhileOffTurnsItOn() { + // Never set: the pair is parked, and the drag takes it off the park. + LoopMarks parked = resolveLoopMarks(StoredLoop{}, kFrames); + CHECK(parked.parked && !parked.hasLoop); + parked.loopStart = 3000; // the drag + parked.hasLoop = true; + const LoopMarks fromParked = writeThenRead(parked); + CHECK(fromParked.hasLoop && !fromParked.parked); + CHECK(fromParked.loopStart == 3000); + + // Span retained: the drag turns it on at the dragged positions, crossfade retained. + LoopMarks retained = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames); + CHECK(!retained.parked && !retained.hasLoop); + retained.loopEnd = 7000; + retained.hasLoop = true; + const LoopMarks fromRetained = writeThenRead(retained); + CHECK(fromRetained.hasLoop); + CHECK(fromRetained.loopStart == 4000 && fromRetained.loopEnd == 7000); + CHECK(fromRetained.crossfade == 300); +} + +// --- the write's own rules ------------------------------------------------------ + +// Dragging the START marker with the enable off must not turn the loop on, and must not +// destroy the crossfade travelling with the retained span. +static void testEditingTheStartMarkerWhileOffLeavesTheEnableAndCrossfadeAlone() { + LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames); + m.start = 512; + const LoopWrite w = applyLoopMarks(m); + CHECK(!w.loop.hasLoop); + CHECK(w.loop.start == 4000 && w.loop.end == 6000); + CHECK(w.crossfade == 300); + CHECK(w.start == 512); +} + +static void testANegativeCrossfadeNeverReachesTheStore() { + LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 0), kFrames); + m.crossfade = -1; + CHECK(applyLoopMarks(m).crossfade == 0); + StoredLoop s = storedSpan(true, 4000, 6000, -1); + CHECK(resolveLoopMarks(s, kFrames).crossfade == 0); +} + +int main() { + testNothingSetParksOnTheDefaultBoundsAndReadsAsNeverSet(); + testAValidSpanSwitchedOffKeepsItsOwnPositions(); + testAnUnusableSpanParksWhateverTheEnableSays(); + testTheOverrideSupersedesTheBankIntrinsic(); + + testEnableOnKeepsTheSpanAndCrossfade(); + testEnableOffRetainsTheSpanAndCrossfade(); + testOffThenOnRestoresTheLoopExactly(); + testCollapsingTheSpanTurnsItOffReparksAndZeroesTheCrossfade(); + testDraggingALoopMarkWhileOffTurnsItOn(); + + testEditingTheStartMarkerWhileOffLeavesTheEnableAndCrossfadeAlone(); + testANegativeCrossfadeNeverReachesTheStore(); + + if (g_fail == 0) { + std::printf("loop_marks: all tests passed\n"); + return 0; + } + std::printf("loop_marks: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_sample_chrome.cpp b/tests/test_sample_chrome.cpp index 264f40e..331c347 100644 --- a/tests/test_sample_chrome.cpp +++ b/tests/test_sample_chrome.cpp @@ -3,7 +3,8 @@ // // Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the // toolbar's fixed right-anchored run in order (Hold, bake, preview, velocity cell, -// Mono|Stereo, Browse) with the title taking the remainder; the velocity and Hold knobs +// Loop Off|On, Mono|Stereo, Browse) with the title taking the remainder and still holding its +// text at the editor's floor; the velocity and Hold knobs // centred in their cells above their labels; the piano strip owning its whole row at every // width; no rect on the // toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview @@ -58,7 +59,10 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() { CHECK(r.navBrowse.width == kNavButtonWidth); CHECK(r.chanStereo.right() <= r.navBrowse.x); CHECK(r.chanMono.right() == r.chanStereo.x); - CHECK(r.velCell.right() <= r.chanMono.x); + CHECK(r.loopOn.right() <= r.chanMono.x); // the enable is immediately left of Mono|Stereo + CHECK(r.loopOff.right() == r.loopOn.x); // its two segments abut, like the channel pair + CHECK(r.loopOff.y == r.chanMono.y && r.loopOff.height == r.chanMono.height); + CHECK(r.velCell.right() <= r.loopOff.x); CHECK(r.preview.right() <= r.velCell.x); CHECK(r.bake.right() <= r.preview.x); CHECK(r.bake.width == kBakeButtonWidth); @@ -70,8 +74,8 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() { CHECK(r.title.width > 0); // Every toolbar rect sits inside the toolbar row. - const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.chanMono, - r.chanStereo, r.navBrowse}; + const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.loopOff, + r.loopOn, r.chanMono, r.chanStereo, r.navBrowse}; for (const Rect& it : items) { CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom()); } @@ -84,8 +88,8 @@ static void testChromePartsNeverOverlapAtAnyWidth() { // stay inside its own row, clear of every control. CHECK(!overlaps(r.toolbar, r.rootStrip)); CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom()); - const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.chanMono, - r.chanStereo, r.navBrowse}; + const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.loopOff, r.loopOn, + r.chanMono, r.chanStereo, r.navBrowse}; for (const Rect& it : items) { CHECK(!overlaps(it, r.rootStrip)); CHECK(!overlaps(it, r.title)); @@ -147,6 +151,20 @@ static void testHoldCellIsReservedAndFollowsTheVelocityCellGrammar() { CHECK(wide.holdCell.width == r.holdCell.width); } +// The enable joins a RIGHT-ANCHORED run, so it is charged to the title slot and not to the +// window. kEditorMinWidth itself is guarded by test_deck_groups' derived-floor assertion — this +// is the other half of that contract: the title must still hold its text AT that floor, because +// the agreed remedy if it cannot is to narrow the enable's segments, never to move the floor. +static void testTheControlRunLeavesTheTitleReadableAtTheEditorFloor() { + const ChromeRects r = chromeRects(chromeBand(), kKnob); + // "ReaSampler 9000" (15 chars) plus a bracketed 20-char capture name, at the toolbar font's + // generous ~7 px/char estimate: 38 * 7. + constexpr int kTitleTextFloorPx = 266; + CHECK(r.title.width >= kTitleTextFloorPx); + // Nothing in the run reaches into the title's slot. + CHECK(r.title.right() <= r.holdCell.x); +} + static void testDegenerateBandYieldsNoInvertedRects() { const ChromeRects empty = chromeRects(Rect{}, kKnob); CHECK(empty.toolbar.empty() && empty.controls.empty()); @@ -157,8 +175,8 @@ static void testDegenerateBandYieldsNoInvertedRects() { kKnob); const Rect items[] = {tiny.title, tiny.holdCell, tiny.holdKnob, tiny.holdLabel, tiny.bake, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel, - tiny.chanMono, tiny.chanStereo, tiny.navBrowse, - tiny.rootStrip}; + tiny.loopOff, tiny.loopOn, tiny.chanMono, tiny.chanStereo, + tiny.navBrowse, tiny.rootStrip}; for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y); } @@ -206,6 +224,7 @@ int main() { testStripOwnsItsWholeRowAndGrowsWithTheWindow(); testVelocityKnobIsCentredInItsCellAboveTheLabel(); testHoldCellIsReservedAndFollowsTheVelocityCellGrammar(); + testTheControlRunLeavesTheTitleReadableAtTheEditorFloor(); testDegenerateBandYieldsNoInvertedRects(); testPreviewGlyphSitsInsideTheButtonAndPointsRight(); testPreviewGlyphDegradesRatherThanOverflowing(); diff --git a/tests/test_spline_edit.cpp b/tests/test_spline_edit.cpp index 5346e1f..c9b6447 100644 --- a/tests/test_spline_edit.cpp +++ b/tests/test_spline_edit.cpp @@ -107,7 +107,7 @@ static void testOverlayBoxIsTheWholeArea() { // --- Smallest-target-first: resolveWaveformClaim, the shell's own comparison chain ----- // // editor_input_waveform.cpp's mouseDownWaveform resolves a click among a contour node (a fixed -// pick box), the crossfade tab, and a marker's full-height column by calling +// pick box), a mark's CAP, and a mark's full-height column by calling // resolveWaveformClaim with each candidate's own target area; the smallest hit wins. These // tests build the real geometry over the pure primitives the shell composes, then feed it into // resolveWaveformClaim itself, so a reverted node-first/marker-first/tab-first ordering fails @@ -215,7 +215,45 @@ static void testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere() { WaveformClaimant::kMarker); } -// The only live tie: the crossfade tab (<=110) can equal the node (169) only off-geometry, but +// Case (d): every mark now carries a cap, which RESOLVES the long-open "staged-envelope-node +// shadow at zero-attack" wart. A zero-attack AttackEnd node sits at the canvas's top-left — the +// same pixel a START marker at frame 0 draws at — and used to win the click outright, because +// the marker's only target there was its full-height column. START's cap is the same 11x10 tab +// the crossfade always had, so the node no longer shadows it. The cap slot's nominal area is +// unchanged by the change (every mark's cap is one markerHandleRect), which is why the +// arbitration itself needed no re-tuning: cap < node < column still holds. +static void testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip() { + const Rect a = Rect{20, 10, 1000, kWaveformMinHeight}; + const OverlayArea overlay = overlayOf(a); + const std::int64_t frames = 100000; + // START at frame 0: its cap clips against the band's left edge, and a zero-attack node is + // drawn on that same corner. + const Rect cap = markerHandleRect(overlay, frames, 0); + CHECK(!cap.empty()); + CHECK(contains(cap, a.x, a.y)); + + // NOMINAL, matching what the shell feeds the arbitration — the clipped tab at frame 0 is + // the worst case for the cap, and it still wins on the nominal number the shell uses. + const std::int64_t capArea = + static_cast(2 * kMarkerHandleHalfWidth + 1) * kMarkerHandleHeight; + const std::int64_t columnArea = + static_cast(2 * kMarkerGrabWidth + 1) * a.height; + CHECK(capArea < kNodeArea); + CHECK(kNodeArea < columnArea); + + const WaveformClaim node{true, kNodeArea}; + const WaveformClaim capClaim{true, capArea}; + const WaveformClaim column{true, columnArea}; + CHECK(resolveWaveformClaim(node, capClaim, column, SplineGesture::kLeft) == + WaveformClaimant::kTab); + // And the node keeps everything below the cap strip, which is where it is actually drawn + // for any non-degenerate envelope. + CHECK(!contains(cap, a.x, a.y + kMarkerHandleHeight)); + CHECK(resolveWaveformClaim(node, WaveformClaim{}, column, SplineGesture::kLeft) == + WaveformClaimant::kNode); +} + +// The only live tie: a mark's cap (<=110) can equal the node (169) only off-geometry, but // tab-vs-marker ties at overlay height 10 (kMarkerHandleHeight), where the tab's 11x10 strip // (110) equals a marker column's 11 * 10 (110) — the tab wins, matching check order. static void testTabWinsAGenuineTabVersusMarkerTie() { @@ -269,6 +307,7 @@ int main() { testFreshRampDownEndpointBeatsTheStartMarkerAtFrameZero(); testCrossfadeTabBeatsAContourNodeNearItsTopStrip(); testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere(); + testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip(); testTabWinsAGenuineTabVersusMarkerTie(); testNoHitAnywhereFallsThroughToNone(); testAMissedCandidateNeverWinsOnADegenerateZeroArea(); diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp index d74627f..d5e139f 100644 --- a/tests/test_waveform_view.cpp +++ b/tests/test_waveform_view.cpp @@ -8,7 +8,10 @@ // markerHandleRect (the top-strip tab that keeps coincident markers independently grabbable); // resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width // no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower, -// no-crossing keeps target, target clamp, degenerate buffers); waveformSurface (two stacked +// 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, +// the suppression rule and its promoted-first placement, the crossfade wedge ramp); +// waveformSurface (two stacked // lanes L-over-R in stereo, one lane in mono AND for a mono source, overlay always the full // stacked height, grabs reaching the lower lane); laneEnvelope (per-lane channel split). @@ -361,18 +364,18 @@ static void testMarkerHandleOnDegenerateAreas() { CHECK(markerHandleRect(overlayOf(thin), 1000, 500).height == 4); } -// The shell (editor_input_waveform.cpp) checks the loop crossfade's own grab handle — at -// loopStart - crossfade — before it iterates the ordinary marker array, because a zero-length -// fade puts that handle exactly on the loop-start marker's frame. The same coincidence recurs -// whenever ANY marker shares that frame, most plausibly the START marker dragged up against the -// fade edge: this module can't exercise the shell's check-order itself, but it can prove the -// geometric ambiguity that makes the ordering load-bearing — the array's own first-match rule -// would otherwise resolve the top strip to the START marker, not the fade handle. +// The shell (editor_input_waveform.cpp) resolves a mark's CAP before it iterates the ordinary +// marker array, because a zero-length fade puts the crossfade cap exactly on the loop-end +// marker's frame. The same coincidence recurs whenever ANY marker shares that frame, most +// plausibly the START marker dragged up against the fade edge: this module can't exercise the +// shell's check-order itself, but it can prove the geometric ambiguity that makes the ordering +// load-bearing — the array's own first-match rule would otherwise resolve the top strip to the +// START marker, not the fade handle. static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() { const Rect a = wideArea(); - const std::int64_t loopStart = 400, crossfade = 30; - const std::int64_t fadeEdge = loopStart - crossfade; // where the crossfade handle sits - const std::int64_t markers[3] = {fadeEdge, loopStart, loopStart + 100}; // start dialled here + const std::int64_t loopEnd = 400, crossfade = 30; + const std::int64_t fadeEdge = loopEnd - crossfade; // where the crossfade cap sits + const std::int64_t markers[3] = {fadeEdge, 200, loopEnd}; // start dialled onto the fade edge const int mx = frameToX(overlayOf(a), 1000, fadeEdge); const int topY = a.y; // inside the handle's top strip // Without the shell's priority check, the array's own first-match rule already resolves the @@ -380,10 +383,188 @@ static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() { CHECK(markerAtPoint(overlayOf(a), 1000, markers, 3, mx, topY) == 0); // ...and the fade handle's rect claims the exact same pixel — the ambiguity the shell // resolves by smallest-target-first (the handle's clipped tab is always the narrower - // target), same as it does for the zero-fade/loop-start case. + // target), same as it does for the zero-fade/loop-end case. CHECK(contains(markerHandleRect(overlayOf(a), 1000, fadeEdge), mx, topY)); } +// --- The four marks: cap resolve, labels, suppression, crossfade wedge ---------- + +static WaveMarks marksAt(std::int64_t start, std::int64_t loopStart, std::int64_t loopEnd, + std::int64_t xfade, bool loopPresent) { + WaveMarks m; + m.frame[0] = start; + m.frame[1] = loopStart; + m.frame[2] = loopEnd; + m.frame[3] = xfade; + m.present[0] = true; + m.present[1] = m.present[2] = m.present[3] = loopPresent; + return m; +} + +static void testEveryMarkAnswersItsOwnCap() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const WaveMarks m = marksAt(50, 300, 700, 620, true); + for (int i = 0; i < kWaveMarkCount; ++i) { + const int mx = frameToX(ov, 1000, m.frame[i]); + CHECK(capAtPoint(ov, 1000, m, mx, a.y) == i); + CHECK(capAtPoint(ov, 1000, m, mx, a.y + kMarkerHandleHeight - 1) == i); + // Below the cap strip is the column's, never the cap's. + CHECK(capAtPoint(ov, 1000, m, mx, a.y + kMarkerHandleHeight) == -1); + } +} + +static void testAMarkThatIsNotPresentAnswersNoCap() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const WaveMarks m = marksAt(50, 300, 700, 620, /*loopPresent=*/false); + CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 300), a.y) == -1); + CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 620), a.y) == -1); + CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 50), a.y) == 0); // START stays live +} + +// The separability argument the reverse cap order exists for: for a coincident PAIR, one mark +// answers the cap and the OTHER answers the full-height column, so neither is ever stranded. +static void testACoincidentPairStaysSeparableAcrossCapAndColumn() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const int midY = a.y + a.height / 2; + // Zero-length fade: the crossfade mark sits at loopEnd - 0, i.e. exactly on the END marker. + // This is the live case — the crossfade is anchored to the seam it closes. + { + const WaveMarks m = marksAt(50, 300, 700, 700, true); + const int mx = frameToX(ov, 1000, 700); + CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast(WaveMark::kCrossfade)); + const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]}; + CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast(WaveMark::kLoopEnd)); + } + // START dragged onto the loop start: the cap goes to LOOP, the column to START. + { + const WaveMarks m = marksAt(300, 300, 700, 100, true); + const int mx = frameToX(ov, 1000, 300); + CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast(WaveMark::kLoopStart)); + const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]}; + CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast(WaveMark::kStart)); + } + // START dragged onto the loop end: the cap goes to END, the column to START. + { + const WaveMarks m = marksAt(700, 300, 700, 100, true); + const int mx = frameToX(ov, 1000, 700); + CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast(WaveMark::kLoopEnd)); + const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]}; + CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast(WaveMark::kStart)); + } +} + +// The crossfade is the one mark with NO full-height column, so it must never lose a cap tie. +static void testTheCrossfadeCapOutranksEveryOtherMark() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const WaveMarks m = marksAt(400, 400, 400, 400, true); // every mark on one frame + CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 400), a.y) == + static_cast(WaveMark::kCrossfade)); +} + +static void testLabelSidesKeepEachLabelOutOfTheSpanItBounds() { + CHECK(!markLabelLeftOfLine(WaveMark::kStart)); + CHECK(!markLabelLeftOfLine(WaveMark::kLoopStart)); + CHECK(markLabelLeftOfLine(WaveMark::kLoopEnd)); + CHECK(markLabelLeftOfLine(WaveMark::kCrossfade)); + + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const int mx = frameToX(ov, 1000, 500); + const Rect right = markLabelRect(ov, 1000, 500, /*leftOfLine=*/false, 30); + const Rect left = markLabelRect(ov, 1000, 500, /*leftOfLine=*/true, 30); + CHECK(right.x == mx + kMarkLabelGap && right.width == 30); + CHECK(left.right() == mx - kMarkLabelGap && left.width == 30); + // Directly under the cap strip, so caps and labels never fight for the same pixels. + CHECK(right.y == a.y + kMarkerHandleHeight && right.height == kMarkLabelHeight); + CHECK(left.y == right.y); +} + +static void testALabelIsNudgedInsideTheAreaRatherThanClipped() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + // At frame 0 a right-side label would still fit; at the last frame it would overhang. + const Rect atEnd = markLabelRect(ov, 1000, 1000, /*leftOfLine=*/false, 40); + CHECK(atEnd.width == 40); + CHECK(atEnd.right() == a.right()); + const Rect atStart = markLabelRect(ov, 1000, 0, /*leftOfLine=*/true, 40); + CHECK(atStart.width == 40); + CHECK(atStart.x == a.x); + // Wider than the whole band, or no band to draw in: nothing placed. + CHECK(markLabelRect(ov, 1000, 500, false, a.width + 1).empty()); + CHECK(markLabelRect(overlayOf(Rect{0, 0, 200, kMarkerHandleHeight}), 1000, 500, false, 20) + .empty()); +} + +static void testOverlappingLabelsAreSuppressedInPlacementOrder() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + // LOOP labels right of its line at 500, XFADE left of its line at 520: the two boxes point + // at each other and cannot both fit. (LOOP and END never collide however close they get — + // their labels point away from the span they bound.) + const WaveMarks m = marksAt(50, 500, 900, 520, true); + const int w[kWaveMarkCount] = {36, 32, 26, 40}; + const WaveMarkLabels lab = layoutMarkLabels(ov, 1000, m, w, /*promoted=*/-1); + CHECK(!lab.box[0].empty()); // START, far away, always placed + CHECK(!lab.box[1].empty()); // LOOP placed before XFADE, so LOOP wins + CHECK(!lab.box[2].empty()); // END, far away, always placed + CHECK(lab.box[3].empty()); // XFADE suppressed + // Every placed box is disjoint from every other. + for (int i = 0; i < kWaveMarkCount; ++i) { + for (int j = i + 1; j < kWaveMarkCount; ++j) { + if (lab.box[i].empty() || lab.box[j].empty()) continue; + CHECK(lab.box[i].x >= lab.box[j].right() || lab.box[j].x >= lab.box[i].right()); + } + } +} + +// The promoted mark is placed FIRST, so grabbing or hovering a mark always shows its label — +// even the one the resting layout suppresses. +static void testThePromotedMarkIsNeverTheSuppressedOne() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const WaveMarks m = marksAt(50, 500, 900, 520, true); + const int w[kWaveMarkCount] = {36, 32, 26, 40}; + CHECK(layoutMarkLabels(ov, 1000, m, w, -1).box[3].empty()); // XFADE suppressed at rest + const WaveMarkLabels grabbed = + layoutMarkLabels(ov, 1000, m, w, static_cast(WaveMark::kCrossfade)); + CHECK(!grabbed.box[3].empty()); // and placed when it is the one being grabbed + CHECK(grabbed.box[1].empty()); // LOOP yields to it instead +} + +static void testAbsentMarksTakeNoLabel() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const WaveMarks m = marksAt(50, 300, 700, 620, /*loopPresent=*/false); + const int w[kWaveMarkCount] = {36, 32, 26, 40}; + const WaveMarkLabels lab = layoutMarkLabels(ov, 1000, m, w, -1); + CHECK(!lab.box[0].empty()); + CHECK(lab.box[1].empty() && lab.box[2].empty() && lab.box[3].empty()); +} + +static void testTheCrossfadeWedgeRampsToItsPeakAtTheSeam() { + // Zero at the fade's start, the peak at its last column, monotone in between. + CHECK(crossfadeWedgeHeight(100, 200, 100) == 0); + CHECK(crossfadeWedgeHeight(100, 200, 199) == kCrossfadeWedgePx); + int prev = -1; + for (int x = 100; x < 200; ++x) { + const int h = crossfadeWedgeHeight(100, 200, x); + CHECK(h >= prev); + CHECK(h >= 0 && h <= kCrossfadeWedgePx); + prev = h; + } + // Outside the span it contributes nothing, so a caller can sweep a wider range safely. + CHECK(crossfadeWedgeHeight(100, 200, 99) == 0); + CHECK(crossfadeWedgeHeight(100, 200, 200) == 0); + // Degenerate spans: an empty one draws nothing, a one-column one is all peak. + CHECK(crossfadeWedgeHeight(100, 100, 100) == 0); + CHECK(crossfadeWedgeHeight(100, 99, 100) == 0); + CHECK(crossfadeWedgeHeight(100, 101, 100) == kCrossfadeWedgePx); +} + // --- Per-lane envelope content ------------------------------------------------- static void testAsymmetricStereoLanesCarryDifferentContent() { @@ -460,6 +641,17 @@ int main() { testMarkerHandleOnDegenerateAreas(); testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge(); + testEveryMarkAnswersItsOwnCap(); + testAMarkThatIsNotPresentAnswersNoCap(); + testACoincidentPairStaysSeparableAcrossCapAndColumn(); + testTheCrossfadeCapOutranksEveryOtherMark(); + testLabelSidesKeepEachLabelOutOfTheSpanItBounds(); + testALabelIsNudgedInsideTheAreaRatherThanClipped(); + testOverlappingLabelsAreSuppressedInPlacementOrder(); + testThePromotedMarkIsNeverTheSuppressedOne(); + testAbsentMarksTakeNoLabel(); + testTheCrossfadeWedgeRampsToItsPeakAtTheSeam(); + testAsymmetricStereoLanesCarryDifferentContent(); testLaneEnvelopeRejectsOutOfRangeLane();