Loop: an explicit enable, four named marks with grabbable caps, and the crossfade painted where it is actually heard
hasLoop becomes user-owned with the gestures as shortcuts onto it; no format change. START uses overlay/trace, not accent/primary, which is the waveform's own fill.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 <cstdint>
|
||||
#include <optional>
|
||||
|
||||
#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<SampleLoop> override_;
|
||||
std::optional<SampleLoop> intrinsic;
|
||||
std::int64_t crossfade = 0;
|
||||
std::optional<std::int64_t> 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<WaveMark>(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;
|
||||
|
||||
@@ -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<int>(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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<int>(WaveMark::kStart)] = m.start;
|
||||
w.frame[static_cast<int>(WaveMark::kLoopStart)] = m.loopStart;
|
||||
w.frame[static_cast<int>(WaveMark::kLoopEnd)] = m.loopEnd;
|
||||
// The crossfade grows LEFT from the seam it closes, which is where it is audible.
|
||||
w.frame[static_cast<int>(WaveMark::kCrossfade)] = m.loopEnd - m.crossfade;
|
||||
// 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<int>(WaveMark::kStart)] = true;
|
||||
w.present[static_cast<int>(WaveMark::kLoopStart)] = true;
|
||||
w.present[static_cast<int>(WaveMark::kLoopEnd)] = true;
|
||||
w.present[static_cast<int>(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<int>(WaveMark::kLoopStart)] = false;
|
||||
w.present[static_cast<int>(WaveMark::kLoopEnd)] = false;
|
||||
w.present[static_cast<int>(WaveMark::kCrossfade)] = false;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::setLoopEnabled(bool on) {
|
||||
const auto frames = static_cast<std::int64_t>(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);
|
||||
|
||||
@@ -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_) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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<std::int64_t>(tabRect.width) *
|
||||
tabRect.height}
|
||||
: WaveformClaim{};
|
||||
const int capHit = capAtPoint(overlay, frames, grabbable, x, y);
|
||||
const WaveformClaim tab =
|
||||
(capHit >= 0)
|
||||
? WaveformClaim{true, static_cast<std::int64_t>(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<int>(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<std::int64_t>(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<WaveMarker>(capHit), m, frames, x);
|
||||
return true;
|
||||
case WaveformClaimant::kMarker:
|
||||
beginMarkerDrag(static_cast<WaveMarker>(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<std::int64_t>(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<int>(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<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
if (!pcm.empty() && waveMarker_ != WaveMarker::kLoopXfade) {
|
||||
if (!pcm.empty() && waveMarker_ != WaveMarker::kCrossfade) {
|
||||
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(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;
|
||||
|
||||
@@ -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<std::int64_t>(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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<float>(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<float>(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<float>(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<int>(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<int>(waveMarker_)
|
||||
: (hover_.kind == HoverKind::kWaveMark ? hover_.index : -1);
|
||||
int labelW[kWaveMarkCount];
|
||||
for (int i = 0; i < kWaveMarkCount; ++i) labelW[i] = markLabelWidth(static_cast<WaveMark>(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<WaveMark>(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<WaveMark>(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<WaveMark>(promoted)),
|
||||
Font::Micro, Role::TextPrimary);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea) {
|
||||
|
||||
@@ -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<SelectedSample> 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user