Widen the deck row block to 1028 so the filter tie-line is exact, and accumulate the meter's block peaks instead of sampling one in 47

This commit is contained in:
2026-08-02 08:27:05 -04:00
parent 0627398bbb
commit df10ddacc2
29 changed files with 552 additions and 250 deletions
+2 -2
View File
@@ -330,7 +330,7 @@ anything for a trigger shape.
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, loop enable, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
- `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).
- `waveform_view` — the WAVEFORM band's interior: `resolveLaneSplit` is THE lane-split decision (two lanes only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane), free of any pixel geometry so the meter's bar count can ask the same question without a band rect; `waveformSurface` folds it and then measures it against the band, which is why its `laneCount` can still report 1 for a Stereo split on a band too thin to divide. It also yields **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
- **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.
@@ -357,7 +357,7 @@ anything for a trigger shape.
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
labels for them.
- `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveformSurface` folds** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Composes `engine/meter_ballistics` per channel and adds the gain-reduction lamp's own decay; the audio thread's clip flag is ORed in because it is the only latch that sees the blocks between two UI frames. `meterDrawEqual` is what lets the UI tick repaint on change alone.
- `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. `kMeterColumnW` is the SUM of those three, exported so `deck_groups`' MASTER descriptor reserves exactly what the interior consumes — the column is banked to grow, and a reserve that did not track it would underfill or overrun silently. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveform_view`'s `resolveLaneSplit` answers** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Also owns `meterTickNumeralled` (the spec-pinned 0/12/24/36/48/60 numeral set, beside the tick step it derives from), `meterNumeralRect` (bottom-clamped, so the floor tick's numeral cannot hang out of the gutter), and `meterSingleLaneState` — the one bar folds both channels PER FIELD, never picking a whole channel by level. Composes `engine/meter_ballistics` per channel and gives the gain-reduction lamp the peak tick's own hold-then-release, without which a catch smaller than 20 dB × the UI period is dark again before it has been drawn twice; the audio thread's clip flag is ORed in because it is the only latch that sees every block. `meterDrawEqual` is what lets the UI tick repaint on change alone.
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the 92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164.
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
+10 -9
View File
@@ -20,13 +20,11 @@ reasampler_test(capture_browser LINK capture_browser)
reasampler_pure_library(keyboard_strip SOURCES keyboard_strip.cpp LINK PUBLIC editor_geometry)
reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome)
# sample_bands is PRIVATE: the lane split is used internally and nothing in the public
# header needs it.
# sample_bands is PUBLIC since resolveLaneSplit answers in its LaneSplit — the meter's bar
# count consumes that answer, so the type is part of this module's surface, not an internal.
reasampler_pure_library(waveform_view
SOURCES waveform_view.cpp
LINK PUBLIC editor_geometry peaks PRIVATE sample_bands)
# sample_bands is linked directly here because the test exercises the lane metrics that
# waveform_view does not re-export.
LINK PUBLIC editor_geometry peaks sample_bands)
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
@@ -72,12 +70,15 @@ reasampler_test(master_meter LINK master_meter waveform_view)
# PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's
# AudioSample dependency. play_params.h also drags in filter/'s headers (FilterSettings,
# MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked.
# master_meter is PRIVATE: MASTER's descriptor reserves the meter column's own kMeterColumnW,
# but nothing in deck_groups.h names a meter type, so the edge stops at this TU.
reasampler_pure_library(deck_groups
SOURCES deck_groups.cpp
LINK PUBLIC knob_deck velocity_curve peaks curve_law)
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion
# needs the band allocator deck_groups itself has no reason to depend on.
reasampler_test(deck_groups LINK deck_groups sample_bands)
LINK PUBLIC knob_deck velocity_curve peaks curve_law PRIVATE master_meter)
# sample_bands and master_meter are linked directly for the test: the deck-fits-the-floor-window
# assertion needs the band allocator, and the MASTER-reserve identity needs the column width the
# PRIVATE edge above does not re-export.
reasampler_test(deck_groups LINK deck_groups sample_bands master_meter)
# The point-editing grammar both spline consumers share, so it links the curve itself (unlike
# envelope_overlay/envelope_edit, which stay engine-free — the staged envelopes touch no curve).
+5 -5
View File
@@ -4,6 +4,8 @@
#include <utility>
#include "core/instrument/ui/master_meter.h" // kMeterColumnW (what the column's interior needs)
namespace reasampler::instrument::ui {
namespace {
@@ -15,10 +17,6 @@ double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi
// row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen
// the CONTOUR row, which has 144px of slack, not the SOUND row.
constexpr int kEnvModeSegW = 23;
// The output meter's column, right of MASTER's cell slots. 62 + the 60px cell + the gap + the
// group's own padding is exactly kDeckSpanningW.
constexpr int kMasterMeterW = 62;
} // namespace
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
@@ -142,7 +140,9 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true};
master.captionToggle = {id(DeckParam::kLimiterEnable), 32};
master.cellIds = {id(DeckParam::kMasterGain), -1};
master.column = {id(DeckParam::kMasterMeter), kMasterMeterW};
// The reserve IS what the interior consumes — read from master_meter rather than
// restated, so the two cannot drift when the column grows into MASTER's banked room.
master.column = {id(DeckParam::kMasterMeter), kMeterColumnW};
out.push_back(std::move(master));
}
for (DeckGroupDesc& d : out) d.row = deckRowFor(static_cast<DeckGroupId>(d.id));
+6 -2
View File
@@ -57,10 +57,14 @@ enum class DeckRow { Sound, Contour, Spanning };
// from the first two — kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2*kPad — and the
// identity is asserted in test_deck_groups.cpp rather than coded, so the allocator keeps no
// include edge to this header.
inline constexpr int kDeckRowBlockW = 1020; // the block both categorical rows justify inside
// 1028 is the width at which the justification law puts BOTH rows' filter groups on the same
// right edge (x = 640 block-relative) AND divides row 1's slack into three equal gutters. The
// narrower 1020 delivered neither: 40 px over three gutters is 13⅓, so the law produced
// 14/13/13 and left row 1's filter edge 2 px past row 2's.
inline constexpr int kDeckRowBlockW = 1028; // the block both categorical rows justify inside
inline constexpr int kDeckSpanningW = 142; // the right-anchored spanning deck, outside the block
// The hard ceiling the floor may not exceed lives beside the floor itself, in sample_bands.h's
// kEditorCeilingWidth — a window fact, not a deck one. Today's gap between the two is 90px,
// kEditorCeilingWidth — a window fact, not a deck one. Today's gap between the two is 82px,
// the whole width budget for the life of this layout (asserted in test_deck_groups.cpp) — see
// instrument-control-surface.md §1.6 before spending any of it.
+49 -6
View File
@@ -4,9 +4,14 @@
namespace reasampler::instrument::ui {
bool meterTickNumeralled(int db) {
// Every OTHER 6 dB tick, which is the 0/12/24/36/48/60 set the scale is specified as.
return db % (2 * static_cast<int>(kMeterTickStepDb)) == 0;
}
MeterRects meterRects(const Rect& column, LaneSplit split) {
MeterRects r;
if (column.width <= 0 || column.height <= 0) return r;
if (column.width < kMeterColumnW || column.height <= 0) return r;
r.labels = Rect::ltrb(column.x, column.y, column.x + kMeterLabelW, column.bottom());
const int fieldLeft = column.x + kMeterLabelW + kMeterLabelGap;
r.field = Rect::ltrb(fieldLeft, column.y, fieldLeft + kMeterFieldW, column.bottom());
@@ -21,6 +26,18 @@ MeterRects meterRects(const Rect& column, LaneSplit split) {
return r;
}
Rect meterNumeralRect(const Rect& labels, int y) {
if (labels.empty()) return {};
int top = y - 5;
if (top < labels.y) top = labels.y;
int bottom = top + 10;
if (bottom > labels.bottom()) {
bottom = labels.bottom();
top = bottom - 10 < labels.y ? labels.y : bottom - 10;
}
return Rect::ltrb(labels.x, top, labels.right(), bottom);
}
int meterDbToY(const Rect& field, double db) {
const double norm = engine::meterNormFromDb(db);
const int y = field.bottom() - static_cast<int>(norm * field.height + 0.5);
@@ -34,9 +51,9 @@ MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& blo
MasterMeterUi next;
next.left = engine::advanceMeter(prev.left, block.peakL, elapsedSeconds);
next.right = engine::advanceMeter(prev.right, block.peakR, elapsedSeconds);
// The audio thread's latch is the authoritative one: it sees every block, where this only
// ever samples the last block before the UI woke. A clip that came and went between two
// frames is invisible to the peaks above and would otherwise be lost.
// ORed in unconditionally. Now that the peaks accumulate, advanceMeter's own >= 0 dBFS
// check sees the same window and would latch too — but the published flag stays the
// definitive one, and it is the half clearMasterBusClip resets.
if (block.clip) {
next.left.clip = true;
next.right.clip = true;
@@ -44,8 +61,23 @@ MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& blo
const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0;
const double reduction = -engine::meterDbFromLinear(block.minGain);
const double fallen = prev.reductionDb - engine::kMeterFallDbPerSecond * dt;
next.reductionDb = reduction > fallen ? reduction : fallen;
// Same hold-then-release shape as the peak tick, for the same reason: at the UI period the
// lamp actually runs at, a bare decay retires a catch before it has been drawn twice.
if (reduction >= prev.reductionDb) {
next.reductionDb = reduction;
next.reductionHoldSeconds = engine::kMeterPeakHoldSeconds;
} else {
next.reductionDb = prev.reductionDb;
next.reductionHoldSeconds = prev.reductionHoldSeconds - dt;
if (next.reductionHoldSeconds < 0.0) {
// Spend the overshoot as fall time so the release does not quantize to whichever
// UI frame the hold happened to expire on.
const double fallen =
next.reductionDb - engine::kMeterFallDbPerSecond * -next.reductionHoldSeconds;
next.reductionDb = fallen > reduction ? fallen : reduction;
next.reductionHoldSeconds = 0.0;
}
}
if (next.reductionDb < 0.0) next.reductionDb = 0.0;
return next;
}
@@ -59,6 +91,17 @@ MasterMeterUi clearMasterMeterClip(MasterMeterUi prev) {
return next;
}
engine::MeterState meterSingleLaneState(const MasterMeterUi& m) {
engine::MeterState s;
s.levelDb = m.left.levelDb > m.right.levelDb ? m.left.levelDb : m.right.levelDb;
s.holdDb = m.left.holdDb > m.right.holdDb ? m.left.holdDb : m.right.holdDb;
s.holdRemainingSeconds = m.left.holdRemainingSeconds > m.right.holdRemainingSeconds
? m.left.holdRemainingSeconds
: m.right.holdRemainingSeconds;
s.clip = m.left.clip || m.right.clip;
return s;
}
bool grLampLit(const MasterMeterUi& m) { return m.reductionDb >= kGrLampFloorDb; }
bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b) {
+32 -4
View File
@@ -17,9 +17,18 @@ inline constexpr int kMeterLabelGap = 4;
inline constexpr int kMeterFieldW = 36; // one 36px bar, or two 17px bars kMeterBarGap apart
inline constexpr int kMeterBarGap = 2;
// What the interior consumes, and therefore the width the deck must RESERVE for the column.
// knob_deck's MASTER descriptor reads this rather than restating 62 — §1.2 banks MASTER's
// growth to 236 as the meter's growth room, so this constant is expected to move.
inline constexpr int kMeterColumnW = kMeterLabelW + kMeterLabelGap + kMeterFieldW;
// A tick every 6 dB up the scale; every other one carries a numeral, and 0 dB draws heavier.
inline constexpr double kMeterTickStepDb = 6.0;
// Whether the tick at `db` carries a numeral. The numeral SET is spec-pinned (0, 12, 24,
// 36, 48, 60), so it lives beside the step it is derived from rather than in the painter.
bool meterTickNumeralled(int db);
struct MeterRects {
Rect labels; // the numeral gutter
Rect field; // the whole bar field
@@ -27,28 +36,39 @@ struct MeterRects {
Rect barB; // empty() unless Stereo
};
// `split` is the RESOLVED lane decision waveformSurface already folds (channel mode AND the
// `split` is the RESOLVED lane decision resolveLaneSplit already folds (channel mode AND the
// source's channel count), not "is the instrument in stereo mode": a mono source under stereo
// mode is dual-mono, and two identical bars would be a lie. One source, two views, one rule.
// A column narrower than kMeterColumnW yields nothing rather than an overrunning field.
MeterRects meterRects(const Rect& column, LaneSplit split);
// y of `db` inside the bar field — kMeterTopDb at the top edge, kMeterFloorDb at the bottom,
// linear in dB between, clamped outside.
int meterDbToY(const Rect& field, double db);
// The numeral's label rect for the tick at `y`, kept inside the gutter: the floor tick sits ON
// the field's bottom edge, and an unclamped y±5 box would hang below the column.
Rect meterNumeralRect(const Rect& labels, int y);
// The per-instance UI state behind the column. One clip latch per channel (the cap is drawn
// once, over whichever of them tripped).
struct MasterMeterUi {
engine::MeterState left;
engine::MeterState right;
double reductionDb = 0.0; // how far the limiter is pulling gain down; 0 = not working
// The lamp's hold, on the SAME principle (and the same window) as the peak tick's: without
// it a catch smaller than kMeterFallDbPerSecond x the UI period is fully decayed by the
// next frame and the lamp never draws lit at all.
double reductionHoldSeconds = 0.0;
};
// What the audio thread published about the last block, in this module's own vocabulary.
// What the audio thread published SINCE THE LAST READ, in this module's own vocabulary — the
// peaks are a max over every block in that window and minGain a min, so no block is discarded
// unseen between two UI frames.
struct MasterMeterBlock {
double peakL = 0.0;
double peakR = 0.0;
double minGain = 1.0; // the limiter's smallest gain over the block; 1 = no reduction
double minGain = 1.0; // the limiter's smallest gain over the window; 1 = no reduction
bool clip = false; // the AUDIO thread's own latch
};
@@ -58,13 +78,21 @@ MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& blo
bool meterClipped(const MasterMeterUi& m);
MasterMeterUi clearMasterMeterClip(MasterMeterUi prev);
// What the ONE bar shows on a single-lane column: the two channels folded per FIELD, not the
// louder channel's whole state. Picking a channel by level would draw a hold tick and a clip
// belonging to whichever won on level — inert while L ≡ R on every path that reaches Single,
// and wrong the moment they diverge.
engine::MeterState meterSingleLaneState(const MasterMeterUi& m);
// Whether two states would DRAW the same, so the UI tick can repaint only on a change and an
// idle editor costs nothing. Compares what the column shows — the bar, the held tick, the cap
// and the lamp — not every stored double.
bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b);
// The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs
// a floor rather than a bare non-zero test.
// a floor rather than a bare non-zero test. 0.5 dB is a CHOSEN floor, not a measurement — the
// spec asks only for "a small floor". Lowering it makes the lamp flicker on limiting too slight
// to hear; raising it hides genuine catches, since the limiter's ceiling is only 0.3 dBTP.
inline constexpr double kGrLampFloorDb = 0.5;
bool grLampLit(const MasterMeterUi& m);
+2 -2
View File
@@ -21,12 +21,12 @@ inline constexpr int kPad = 8;
// this allocator is deliberately independent of the deck (it takes deckHeight as a parameter
// for exactly that reason), so the derivation is asserted in test_deck_groups.cpp — the one
// place that already includes both headers — rather than coded as an include edge.
inline constexpr int kEditorMinWidth = 1190;
inline constexpr int kEditorMinWidth = 1198;
inline constexpr int kEditorMinHeight = 680;
// The hard ceiling the floor above may not exceed; the window itself still grows freely above
// it. A window fact, sibling of kEditorMinWidth/kEditorMinHeight, not a deck one — moved here
// from knob_deck.h for that reason. The gap to the floor (today: 90px) is the deck's whole
// from knob_deck.h for that reason. The gap to the floor (today: 82px) is the deck's whole
// width budget, spent once; the identity is asserted in test_deck_groups.cpp, the one place
// that already includes both this header and knob_deck.h.
inline constexpr int kEditorCeilingWidth = 1280;
+5 -3
View File
@@ -24,13 +24,15 @@ OverlayArea waveformOverlayArea(const Rect& band) {
return OverlayArea{band.empty() ? Rect{} : band};
}
LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels) {
return (stereoMode && sourceChannels >= 2) ? LaneSplit::Stereo : LaneSplit::Single;
}
WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels) {
WaveformSurface s;
if (band.empty()) return s;
s.overlay = waveformOverlayArea(band);
const bool twoLanes = stereoMode && sourceChannels >= 2;
const WaveformLanes lanes =
waveformLanes(band, twoLanes ? LaneSplit::Stereo : LaneSplit::Single);
const WaveformLanes lanes = waveformLanes(band, resolveLaneSplit(stereoMode, sourceChannels));
s.upper = lanes.upper;
s.lower = lanes.lower;
// Derived from the resolved lanes, not `twoLanes` — a stereo split's integer division
+11 -3
View File
@@ -11,6 +11,7 @@
#include <cstdint>
#include "core/instrument/ui/editor_geometry.h" // Rect, OverlayArea, contains
#include "core/instrument/ui/sample_bands.h" // LaneSplit (resolveLaneSplit's answer)
#include "core/audio/peaks.h" // AudioSample (float), Envelope
namespace reasampler::instrument::ui {
@@ -35,9 +36,16 @@ struct WaveformSurface {
// the band-stack allocator's kWaveformMinHeight floor.
};
// Resolves the surface for a waveform band. Two lanes need BOTH stereo mode and a source
// that has a second channel to show: a mono source under stereo mode is dual-mono, so a
// second lane would be the redundant duplicate single-lane mode exists to avoid.
// THE lane-split decision, free of any pixel geometry: two lanes need BOTH stereo mode and a
// source that has a second channel to show, since a mono source under stereo mode is dual-mono
// and a second lane would be the redundant duplicate single-lane mode exists to avoid. The one
// home of that rule — the meter's bar count is the SAME question and reads it here, rather than
// inferring it from a band rect it has no business knowing about.
LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels);
// Resolves the surface for a waveform band, folding the split above and then measuring it
// against the band: WaveformSurface::laneCount can still report 1 for a Stereo split on a band
// too thin to divide, which is a geometry fact and not a second rule.
WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels);
// THE overlay area, standalone — same value as WaveformSurface::overlay, for the hit-test
+19 -4
View File
@@ -107,12 +107,13 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published per block as relaxed atomics (per-channel peak, latched clip, the block's smallest limiter gain). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. `setLimiterEnabled` requests the host's `restartComponent(kLatencyChanged)`, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. `setLimiterEnabled` requests the host's `restartComponent(kLatencyChanged)`, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_interaction.h` — the editor's INTERACTION VOCABULARY: `DragKind` (what a gesture in flight is editing) and `HoverKind`/`HoverTarget` (what the pointer can be over). Split out of `reasampler_editor.h`, which had grown past the ~600-line ceiling with no seam — these two catalogues are produced by the input TUs and read by the paint TUs, and neither is behaviour, which is what makes them a responsibility rather than a bisection. Namespace-scope, so the editor's own members still spell them unqualified. Internal to this TU family, like `editor_internal.h`.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
@@ -124,13 +125,27 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
the very instance whose frame is on the stack. Deferring by one tick is same-thread and
in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
one.
- **The limiter toggle arms on the same principle, and for the same reason.** Its commit
requests the host's `restartComponent(kLatencyChanged)`; a host that services that
synchronously runs `setActive(false)`/`setActive(true)`, and OUR `setActive(true)` calls
`reloadInstrument()` — a WAV re-decode plus disk I/O. Inline from `WM_LBUTTONDOWN` that
whole cycle runs with `SetCapture` held. The click writes the editor's own snapshot and
paints at once; the sync tick calls `setLimiterEnabled`, so **the audio and the reported
latency follow the click by up to one tick.** It sits AFTER the drag guard with the bake:
the restart rebuilds the instance, which mid-drag would yank the edit surface exactly as a
reload would. Every other writer of the parameter set (`setState`, the bake's adopt) still
commits and restarts immediately — none of them is inside a mouse handler.
- **The MASTER meter's ballistics ride the sync tick, and that tick is 500 ms.** They run
BEFORE the tick's in-flight-drag guard on purpose — a drag suppresses the reload poll, but
the bus keeps sounding. Elapsed time is measured (`GetTickCount64`), never assumed from the
timer's period, and the tick repaints only when `meterDrawEqual` says the picture changed.
At that cadence the bar falls 10 dB per redraw and the 1.5 s hold spans three frames:
correct against `meter_ballistics`' contract, coarse to the eye. A meter-rate timer is a
separate change and is not in yet.
**The published block state is therefore ACCUMULATED, not sampled**: at 48 kHz / 512 frames
~47 blocks elapse per tick, so the processor folds a per-channel max and a min limiter gain
across them and `masterBusMeter()` clears the accumulators as it reads. A plain overwriting
store displayed one block in ~47 and lost the rest — the specified "a peak displays on the
first UI frame after it occurs" is what the fold restores. `masterBusMeter()` is CONSUMING,
so exactly one caller may hold it; the embed strip reads its own non-consuming
`embedActivityLevel()`. A meter-rate timer remains a separate change and is not in.
- The bake's availability probe runs on the SAME tick that paints the button, so the
control can never be enabled on one tick and refuse on the next. The bake Hold control's
applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
+2 -2
View File
@@ -98,8 +98,8 @@ void ReaSamplerEditor::dragBrowse(int x, int y) {
invalidate();
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x,
int y) const {
const BrowseModal bm = computeBrowseModal(w, h);
if (contains(bm.back, x, y)) return {HoverKind::kBack, -1};
if (contains(bm.cancel, x, y)) return {HoverKind::kBrowseCancel, -1};
+2 -2
View File
@@ -152,8 +152,8 @@ void ReaSamplerEditor::dragChrome(const FaceLayout& fl, int x, int y) {
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x,
int y) const {
const ChromeRects& cr = fl.chrome;
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
+2 -2
View File
@@ -114,8 +114,8 @@ void ReaSamplerEditor::onMouseRDown(int x, int y) {
/*addOnEmptySpace=*/false);
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
int y) const {
const CurvePopupLayout pl = computeCurvePopup(w, h);
if (contains(pl.close, x, y)) return {HoverKind::kPopupClose, -1};
if (!contains(pl.curveBox, x, y)) return {};
+8 -6
View File
@@ -68,10 +68,12 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
const bool on = (hit.segment == 1);
if (on != params_.limiterEnabled) {
params_.limiterEnabled = on;
// The processor's own funnel mirrors the audio-thread flag and requests the
// host's latency restart; a reload would re-decode a WAV the toggle cannot
// change. The local snapshot moves with it so a later commit agrees.
processor_->setLimiterEnabled(on);
// ARMED here, run on the sync tick — the same treatment the bake gets, and
// for the same reason: the processor's funnel requests the host's latency
// restart, whose deactivate/reactivate calls setActive(true) and re-decodes
// the WAV. Inline, that whole cycle would run nested inside this mouse
// handler with SetCapture held.
limiterPending_ = on;
}
invalidate();
break;
@@ -192,8 +194,8 @@ void ReaSamplerEditor::dragDeck(int x, int y) {
invalidate();
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x,
int y) const {
const Rect& band = fl.bands.decks;
if (!contains(band, x, y)) return {};
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
@@ -188,8 +188,8 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
return true;
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x,
int y) {
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.
//
+55
View File
@@ -0,0 +1,55 @@
// editor_interaction.h — the Sample editor's INTERACTION VOCABULARY: what a drag can be
// editing, and what the pointer can be over. Two catalogues of the editor's interactive
// surface, produced by the input TUs and read by the paint TUs; neither is behaviour, which is
// why they are named here rather than buried inside the editor class between its paint and
// layout declarations. Internal to the reasampler_editor TU family, like editor_internal.h.
#pragma once
namespace reasampler::vst {
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their grabbed
// item in the editor's waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored knob
// drag (control in dragParamId_, grab value in dragKnobStartValue_). kSplineNode is the
// overlay's peer of kCurveNode: the same VelocityCurve point drag, over the waveform overlay's
// box and the overlay-active envelope's contour rather than the popup's box and curve.
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
kCurveNode, kSplineNode, kDeckKnob };
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE.
enum class HoverKind {
kNone,
kNavBrowse, // the chrome "Browse" toolbar button (opens the Browse modal)
kBack, // the Browse "back" affordance (returns to Sample)
kSearchBox, // the browser search box
kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All)
kCard, // a capture card (index = visible_ index)
kBrowseConfirm, // the Browse modal "Load" confirm button
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)
kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id)
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
kCurveNode, // a velocity-curve control point (index = point index)
kVelKnob, // the chrome preview-velocity radial knob
kHoldKnob, // the chrome bake-Hold radial knob
kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip
kPopupClose, // the curve popup's Close (x) button
};
// `index` disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1
// when not applicable.
struct HoverTarget {
HoverKind kind = HoverKind::kNone;
int index = -1;
bool operator==(const HoverTarget& o) const { return kind == o.kind && index == o.index; }
bool operator!=(const HoverTarget& o) const { return !(*this == o); }
};
} // namespace reasampler::vst
+11 -16
View File
@@ -13,7 +13,7 @@
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's state)
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
#include "core/instrument/ui/master_meter.h" // the bus meter's column interior + ballistics
#include "core/instrument/ui/waveform_view.h" // waveformSurface (THE lane-split fold)
#include "core/instrument/ui/waveform_view.h" // resolveLaneSplit (THE lane-split fold)
#include "shell/instrument/editor_internal.h" // kit adapters + knob face
#include "shell/instrument/reasampler_processor.h"
@@ -52,12 +52,11 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
db -= static_cast<int>(kMeterTickStepDb)) {
const int y = meterDbToY(m.field, db);
const bool zero = (db == 0);
const bool numeralled = zero || (db % 12 == 0);
LICE_FillRect(bmp, m.field.x, y, m.field.width, zero ? 2 : 1,
zero ? toLice(roleColor(Role::TextDim)) : hairline, 1.0f, 0);
if (numeralled) {
kitText(bmp, Rect::ltrb(m.labels.x, y - 5, m.labels.right(), y + 5),
tickLabel(db).c_str(), Font::Micro, Role::TextDim, Align::Right);
if (meterTickNumeralled(db)) {
kitText(bmp, meterNumeralRect(m.labels, y), tickLabel(db).c_str(), Font::Micro,
Role::TextDim, Align::Right);
}
}
@@ -78,9 +77,7 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
}
};
if (split == LaneSplit::Single) {
const instrument::engine::MeterState& loudest =
state.left.levelDb >= state.right.levelDb ? state.left : state.right;
drawBar(m.barA, loudest);
drawBar(m.barA, meterSingleLaneState(state));
} else {
drawBar(m.barA, state.left);
drawBar(m.barB, state.right);
@@ -102,14 +99,12 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
const PlaySeconds& play = params_.play;
const bool isMono = (voiceMode_ == VoiceMode::Mono);
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
// The meter's bar count is the SAME resolved decision the waveform's lane split is — read
// off waveformSurface rather than re-derived, so it can never become a second rule.
const LaneSplit meterSplit =
waveformSurface(fl.bands.waveform, channelMode_ == ChannelMode::Stereo,
channelPcmFor(selectedId_).channelCount)
.laneCount == 2
? LaneSplit::Stereo
: LaneSplit::Single;
// The meter's bar count is the SAME resolved decision the waveform's lane split is —
// resolveLaneSplit is the one home of it. Asked directly rather than read back off
// waveformSurface, whose laneCount additionally folds in the waveform BAND's pixel height,
// which decides nothing about how many channels the bus is carrying.
const LaneSplit meterSplit = resolveLaneSplit(channelMode_ == ChannelMode::Stereo,
channelPcmFor(selectedId_).channelCount);
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
+11 -3
View File
@@ -108,9 +108,8 @@ void ReaSamplerEditor::onSyncTimer() {
// tick picks up the change after release.
if (!processor_) return;
// The meter runs on EVERY tick, the in-flight-drag case included: a drag suppresses the
// reload poll below, but the bus keeps sounding and a frozen bar would misreport it.
// Elapsed time is measured rather than assumed — WM_TIMER's period is a request.
// Ahead of the drag guard on purpose: a drag suppresses the reload poll below, but the bus
// keeps sounding and a frozen bar would misreport it.
{
const unsigned long long now = GetTickCount64();
const double elapsed = meterTickMs_ == 0
@@ -129,6 +128,15 @@ void ReaSamplerEditor::onSyncTimer() {
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
// The limiter click armed it; this is where it runs. Past the drag guard with the bake,
// because the restart it requests makes the host rebuild this instance — mid-drag that
// would yank the edit surface exactly as a reload would.
if (limiterPending_) {
const bool on = *limiterPending_;
limiterPending_.reset();
processor_->setLimiterEnabled(on);
}
// Resolve the bake affordance's availability on the SAME tick that paints it, so it
// can never be enabled on one tick and refuse on the next.
const bool available = bakeAvailable(processor_->bridge());
+9 -4
View File
@@ -183,11 +183,16 @@ void ReaSamplerProcessor::setLimiterEnabled(bool on) {
setInstrumentParams(params);
}
MasterBusMeter ReaSamplerProcessor::masterBusMeter() const {
MasterBusMeter ReaSamplerProcessor::masterBusMeter() {
MasterBusMeter m;
m.peakL = meterPeakL_.load(std::memory_order_relaxed);
m.peakR = meterPeakR_.load(std::memory_order_relaxed);
m.minGain = meterMinGain_.load(std::memory_order_relaxed);
// Exchange, not load: the accumulators hold the window since this was last called, and
// clearing them here is what starts the next window. The audio thread's own fold is a
// load-max-store, so a store landing between this exchange and that store can retain one
// window's peak for one extra frame — it can never LOSE one, which is the property that
// matters for a peak meter.
m.peakL = meterPeakL_.exchange(0.f, std::memory_order_relaxed);
m.peakR = meterPeakR_.exchange(0.f, std::memory_order_relaxed);
m.minGain = meterMinGain_.exchange(1.f, std::memory_order_relaxed);
m.clip = meterClip_.load(std::memory_order_relaxed);
return m;
}
+6 -44
View File
@@ -28,6 +28,7 @@
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state)
#include "shell/instrument/editor_interaction.h" // DragKind / HoverKind / HoverTarget
#ifdef _WIN32
#include <windows.h>
@@ -79,15 +80,6 @@ private:
// picker overlaid on it.
enum class View { kSample, kBrowse };
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
// knob drag (control in dragParamId_, grab value in dragKnobStartValue_).
// kSplineNode is the overlay's peer of kCurveNode: the same VelocityCurve point drag, over
// the waveform overlay's box and the overlay-active envelope's contour rather than the
// popup's box and curve.
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
kCurveNode, kSplineNode, kDeckKnob };
// Which envelope the waveform overlay is drawing and editing. The selection type and its
// whole state machine are the pure deck_groups module's; this alias keeps the shell's
// spelling.
@@ -115,41 +107,6 @@ private:
// 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
// not applicable.
enum class HoverKind {
kNone,
kNavBrowse, // the chrome "Browse" toolbar button (opens the Browse modal)
kBack, // the Browse "back" affordance (returns to Sample)
kSearchBox, // the browser search box
kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All)
kCard, // a capture card (index = visible_ index)
kBrowseConfirm, // the Browse modal "Load" confirm button
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)
kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id)
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
kCurveNode, // a velocity-curve control point (index = point index)
kVelKnob, // the chrome preview-velocity radial knob
kHoldKnob, // the chrome bake-Hold radial knob
kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip
kPopupClose, // the curve popup's Close (x) button
};
struct HoverTarget {
HoverKind kind = HoverKind::kNone;
int index = -1;
bool operator==(const HoverTarget& o) const { return kind == o.kind && index == o.index; }
bool operator!=(const HoverTarget& o) const { return !(*this == o); }
};
// The three-band stack for the current client size, plus the chrome interior. Every
// paint/hit-test path derives both through this one call so draw and hit-test can never
// disagree about where a band is.
@@ -489,6 +446,11 @@ private:
std::string bakeMessage_; // last outcome, shown in the title band
int bakeMessageTicks_ = 0; // sync ticks the message survives
// The limiter toggle, armed by the click and run on the sync tick — the commit requests a
// host latency restart, which is the same nested-inside-a-mouse-handler hazard the bake
// defers for. Empty = nothing armed.
std::optional<bool> limiterPending_;
// The editor-drop -> extension-ingest relay is not shipped (the bridge is read-only):
// an OS drop just flashes a "drop on the panel instead" banner (dropHintTicks_ counts
// down via the sync tick). Never ingests, never inserts a timeline item.
@@ -309,7 +309,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
}
// The chain's last stage before the bus, after the gain above.
const float minGain = limiter_.process(ch0, ch1, frames);
meterMinGain_.store(minGain, std::memory_order_relaxed);
foldMinGain(meterMinGain_, minGain);
// Channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
for (int32 ch = 2; ch < out.numChannels; ++ch) {
if (float* buf = out.channelBuffers32[ch]) {
@@ -324,8 +324,9 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
if (a0 > peakL) peakL = a0;
if (a1 > peakR) peakR = a1;
}
meterPeakL_.store(peakL, std::memory_order_relaxed);
meterPeakR_.store(peakR, std::memory_order_relaxed);
foldPeak(meterPeakL_, peakL);
foldPeak(meterPeakR_, peakR);
advisoryPeak_.store(peakL > peakR ? peakL : peakR, std::memory_order_relaxed);
if (peakL >= 1.f || peakR >= 1.f) meterClip_.store(true, std::memory_order_relaxed);
} else if (ch0) {
// Mono: render into channel 0, replicate to any extra channels (defensive).
@@ -354,14 +355,15 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
}
}
const float minGain = limiter_.process(ch0, nullptr, frames);
meterMinGain_.store(minGain, std::memory_order_relaxed);
foldMinGain(meterMinGain_, minGain);
float peak = 0.f;
for (int32 i = 0; i < frames; ++i) {
const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i];
if (a > peak) peak = a;
}
meterPeakL_.store(peak, std::memory_order_relaxed);
meterPeakR_.store(peak, std::memory_order_relaxed);
foldPeak(meterPeakL_, peak);
foldPeak(meterPeakR_, peak);
advisoryPeak_.store(peak, std::memory_order_relaxed);
if (peak >= 1.f) meterClip_.store(true, std::memory_order_relaxed);
for (int32 ch = 1; ch < out.numChannels; ++ch) {
if (float* buf = out.channelBuffers32[ch]) {
+39 -15
View File
@@ -128,16 +128,17 @@ public:
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
void** obj) override;
// The embedded-strip activity level (0..1) for the embed shell, UI thread. The loudest of
// the two published bus peaks — one publication serves the strip and the meter.
// The embedded-strip activity level (0..1) for the embed shell, UI thread. The last block's
// loudest channel — a non-consuming read, so it stays correct however often the editor's
// meter drains its own accumulators (or never does, with no editor open).
double embedActivityLevel() const {
const float l = meterPeakL_.load(std::memory_order_relaxed);
const float r = meterPeakR_.load(std::memory_order_relaxed);
return static_cast<double>(l > r ? l : r);
return static_cast<double>(advisoryPeak_.load(std::memory_order_relaxed));
}
// What the audio thread published about the output bus last block. UI thread.
MasterBusMeter masterBusMeter() const;
// What the audio thread published since the LAST call: peaks maxed and minGain minimised
// across every block in that window. CONSUMING — it resets the accumulators as it reads, so
// exactly one reader may call it, and that reader is the editor's meter tick. UI thread.
MasterBusMeter masterBusMeter();
void clearMasterBusClip();
// Resolves the selection against the instance-owned SampleRefs, decodes its WAV
@@ -284,13 +285,28 @@ private:
// Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine.
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
// Publishes a silent block to the meter. EVERY process() path that emits no audio calls
// this, or the bar freezes at the last peak it saw. The clip latch is deliberately not
// touched — it survives silence until the user clears it.
// Folds one block's reading into the accumulator it belongs to — a running max for a peak,
// a running min for the limiter's gain. Read-modify-write rather than a bare store, so the
// ~47 blocks that elapse between two 500 ms UI frames at 48 kHz/512 all reach the meter
// instead of the one it happened to sample. Relaxed throughout: the accumulators are
// advisory, and no other state is ordered against them. Block rate, never per frame.
static void foldPeak(std::atomic<float>& acc, float blockPeak) {
if (blockPeak > acc.load(std::memory_order_relaxed)) {
acc.store(blockPeak, std::memory_order_relaxed);
}
}
static void foldMinGain(std::atomic<float>& acc, float blockMinGain) {
if (blockMinGain < acc.load(std::memory_order_relaxed)) {
acc.store(blockMinGain, std::memory_order_relaxed);
}
}
// Publishes a silent block. EVERY process() path that emits no audio calls this. It clears
// only the ADVISORY level, which is a last-block reading: the meter accumulators need
// nothing here, because a block that emitted no audio contributes no peak and no gain
// reduction, and the UI's own read is what resets them.
void publishSilentMeterBlock() {
meterPeakL_.store(0.f, std::memory_order_relaxed);
meterPeakR_.store(0.f, std::memory_order_relaxed);
meterMinGain_.store(1.f, std::memory_order_relaxed);
advisoryPeak_.store(0.f, std::memory_order_relaxed);
}
// Mirrors the persisted limiter enable onto the audio thread and the latency reader. Called
@@ -456,13 +472,21 @@ private:
instrument::engine::Limiter limiter_;
std::atomic<bool> limiterEnabled_{false};
// What the audio thread publishes about the output bus each block, relaxed peaks, the
// latched clip, and the limiter's smallest gain. No dB, no ballistics, no hold timer here;
// What the audio thread publishes about the output bus each block, relaxed. The peaks and
// minGain ACCUMULATE (max / min) across every block since the UI last read, and
// masterBusMeter() resets them as it reads — the fix for a bar that displayed roughly one
// block in fifty. No dB, no ballistics, no hold timer here;
// the UI runs those off these values and its own elapsed time.
std::atomic<float> meterPeakL_{0.f};
std::atomic<float> meterPeakR_{0.f};
std::atomic<float> meterMinGain_{1.f};
std::atomic<bool> meterClip_{false};
// The embed strip's activity level: the LAST block's loudest channel, plainly overwritten.
// Separate from the accumulators above because it answers a different question ("how loud
// is it now") and has a different reader — sharing one would make each reader's reset
// silently truncate the other's window.
std::atomic<float> advisoryPeak_{0.f};
};
} // namespace reasampler::vst