Merge branch 'omega-w1-t3-meter-rate' into phase-omega

This commit is contained in:
2026-08-03 14:21:44 -04:00
11 changed files with 360 additions and 101 deletions
+1 -1
View File
@@ -362,7 +362,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. `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.
- `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. Also owns the editor's two paint-dispatch predicates, so neither lives as a bare comment in the shell: `meterFastPathEligible` (is a WM_PAINT dirty rect wholly inside the field?) and `meterBarsWithinField` (the self-containment invariant that fast path rests on), both asserted in `test_master_meter.cpp`.
- `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.
+18
View File
@@ -26,6 +26,24 @@ MeterRects meterRects(const Rect& column, LaneSplit split) {
return r;
}
bool meterFastPathEligible(const MeterRects& m, const Rect& dirty) {
return !m.field.empty() && dirty.x >= m.field.x && dirty.y >= m.field.y &&
dirty.right() <= m.field.right() && dirty.bottom() <= m.field.bottom();
}
namespace {
bool rectWithin(const Rect& outer, const Rect& inner) {
return inner.empty() ||
(inner.x >= outer.x && inner.right() <= outer.right() && inner.y >= outer.y &&
inner.bottom() <= outer.bottom());
}
} // namespace
bool meterBarsWithinField(const MeterRects& m) {
if (m.field.empty()) return m.barA.empty() && m.barB.empty();
return rectWithin(m.field, m.barA) && rectWithin(m.field, m.barB);
}
Rect meterNumeralRect(const Rect& labels, int y) {
if (labels.empty()) return {};
int top = y - 5;
+13
View File
@@ -42,6 +42,19 @@ struct MeterRects {
// A column narrower than kMeterColumnW yields nothing rather than an overrunning field.
MeterRects meterRects(const Rect& column, LaneSplit split);
// Whether `dirty` (a WM_PAINT update rect, already client-clipped) lies wholly inside the bar
// field — the editor's meter-frame fast-path test. Geometric, not a flag: the caller decides
// per paint from the rect Windows actually handed it, never from a remembered "was this a
// meter tick" bit, because Windows unions a meter invalidate with any other pending one into a
// single rcPaint and a flag would then paint a meter frame over a face that had really changed.
bool meterFastPathEligible(const MeterRects& m, const Rect& dirty);
// The structural invariant drawMeterField's self-containment rests on: every bar it paints
// stays inside the field it fills first. The ticks, the held-peak tick and the clip cap are all
// drawn directly off `field`'s own coordinates and so are bounded by construction; the bars are
// the one independently-computed rect that isn't.
bool meterBarsWithinField(const MeterRects& m);
// 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);
+30 -12
View File
@@ -260,21 +260,39 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
detector's 4-sample group delay INSIDE that budget, not on top), bounded by one 500 ms tick.
Narrowing it further means a second deferral mechanism (a posted window message) rather than
the tick — deliberately not built.
- **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.
**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
- **The MASTER meter has its OWN 16 ms timer, beside the 500 ms sync poll.** It runs outside
that poll's in-flight-drag guard on purpose — a drag suppresses the reload poll, but the bus
keeps sounding. Elapsed time is measured (`steady_clock`, whose resolution the rate needs —
`GetTickCount64`'s ~15.6 ms quantized a frame's delta to 0 or 16), never assumed from the
timer's period, a zero delta skips the advance, and the tick repaints only when
`meterDrawEqual` says the picture changed. **The published block state is ACCUMULATED, not
sampled**: the processor folds a per-channel max and a min limiter gain across every block
since the last read and `masterBusMeter()` clears the accumulators as it reads. A plain
overwriting store displayed one block per window 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 — which is why the meter block MOVED to the
meter tick rather than being copied there; the embed strip reads its own non-consuming
`embedActivityLevel()`. **The tick's FIRST read is discarded**, because that caller is the
only consumer: with no editor open the accumulators hold everything since the instance was
created, and advancing off them would open the meter at the session's loudest peak. The clip
latch is not discarded with them — it is a latch the user clears. A meter-rate timer remains a
separate change and is not in.
latch is not discarded with them — it is a latch the user clears.
- **A meter frame repaints the bar FIELD, not the client area**, which is what makes the rate
affordable: `paint` honours `ps.rcPaint` against the field rect `paintDeck` cached, composes
into a RETAINED back buffer (so the rest of the face is still there from the last full
compose) and blits the dirty region alone. The fast path is chosen GEOMETRICALLY, never by a
flag — Windows unions a meter invalidate with any other pending one into a single rcPaint, so
a flag would paint a meter frame over a face that had really changed. What stays outside the
field stays on the full path: the static numeral gutter (AA text re-blended onto itself every
frame thickens) and the GR lamp (drawn straight onto the deck group's gradient, which a
sub-rect fill cannot reproduce), so a lamp transition takes a whole-client repaint. **When the
meter is covered or absent** (Browse, the empty state) `invalidateMeter` skips invalidating
anything at all rather than falling back to a whole-client repaint — the ballistics still
advance on `onMeterTimer`'s own clock, but nothing visible changed, so a 60 FPS whole-client
repaint under a modal sheet would be pure cost for zero pixels shown. **The curve popup does
NOT cover the meter** — its centered sheet clamps to 520x380 (`curve_popup.h`) against the
meter's fixed right-anchored slot, at any resizable window size — so the fast path stays live
under it; `paintMeterField` reapplies the popup's own 0.50-alpha wash to the field alone so a
meter-only frame doesn't flash through it at full brightness.
- 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
+54 -14
View File
@@ -1,7 +1,8 @@
// editor_paint.cpp — the ReaSamplerEditor's paint dispatch: the WM_PAINT entry, the Sample
// face's band composition (chrome / waveform / decks, each drawn by its own TU), the empty
// state, and the drop-affordance banner. Windows-only; draws through the shared kit by
// palette role. All layout math is pure (sample_bands) — this TU only sequences.
// editor_paint.cpp — the ReaSamplerEditor's paint dispatch: the WM_PAINT entry over a retained
// back buffer (with the meter's dirty-rect fast path), the Sample face's band composition
// (chrome / waveform / decks, each drawn by its own TU), the empty state, and the
// drop-affordance banner. Windows-only; draws through the shared kit by palette role. All
// layout math is pure (sample_bands) — this TU only sequences.
#include "shell/instrument/reasampler_editor.h"
@@ -18,20 +19,51 @@ namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …)
using namespace reasampler::instrument::ui; // pure geometry (bands / chrome)
void ReaSamplerEditor::paint(HDC hdc) {
LICE_IBitmap* ReaSamplerEditor::ensureBackBuffer(int w, int h) {
if (w <= 0 || h <= 0) return nullptr;
// resize() is a no-op at the current size, so the steady-state paint allocates nothing.
if (!backBuffer_) backBuffer_ = new LICE_SysBitmap(w, h);
else backBuffer_->resize(w, h);
return backBuffer_;
}
void ReaSamplerEditor::releaseBackBuffer() {
delete backBuffer_;
backBuffer_ = nullptr;
}
void ReaSamplerEditor::paint(HDC hdc, const RECT& dirty) {
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
if (w <= 0 || h <= 0) return;
LICE_SysBitmap bmp(w, h);
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
LICE_IBitmap* bmp = ensureBackBuffer(w, h);
if (!bmp) return;
// The host's update rect, clipped to the client area.
const int dx = (std::max)(0, static_cast<int>(dirty.left));
const int dy = (std::max)(0, static_cast<int>(dirty.top));
const int dr = (std::min)(w, static_cast<int>(dirty.right));
const int db = (std::min)(h, static_cast<int>(dirty.bottom));
if (dr <= dx || db <= dy) return;
// A meter frame's dirty rect lies wholly inside the bar field, and nothing else is drawn
// there — so the field is all that needs redrawing, and the rest of the face is still in the
// back buffer from the last full compose. Decided GEOMETRICALLY rather than by a flag — see
// meterFastPathEligible (master_meter) for why.
if (meterFastPathEligible(meterRects_, Rect::ltrb(dx, dy, dr, db))) {
paintMeterField(bmp);
} else {
// Repopulated by paintDeck when the meter is on screen; left empty by the empty state.
meterRects_ = {};
LICE_Clear(bmp, toLice(roleColor(Role::BgBase)));
// Sample is home; Browse is a full-window modal overlay drawn over it, so the Sample
// face draws first and the modal reads as a sheet layered on top.
paintSample(&bmp, w, h);
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
paintSample(bmp, w, h);
if (view_ == View::kBrowse) paintBrowse(bmp, w, h);
// A transient banner flashed after a file was dropped on this window. It reiterates the
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
@@ -40,15 +72,23 @@ void ReaSamplerEditor::paint(HDC hdc) {
const int bannerTop = (std::min)(kTitleHeight, h);
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH);
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
// hue with a dark label so it reads as "attention, not action".
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
kitTextCentered(&bmp, banner,
// A transient notice, not the live layer — draw it on the accent-tertiary
// categorical hue with a dark label so it reads as "attention, not action".
fillSurface(bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
kitTextCentered(bmp, banner,
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
Font::Label, Role::BgBase);
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
// Browse is a full-window modal that covers the meter, so the fast path must not paint
// through it even if something else invalidates a rect that happens to match the stale
// field bounds — invalidateMeter itself already skips while covered (see there). The
// curve popup's sheet never reaches the meter column (see invalidateMeter), so its
// meterRects_ stays valid and the fast path stays live under the popup's wash.
if (view_ == View::kBrowse) meterRects_ = {};
}
BitBlt(hdc, dx, dy, dr - dx, db - dy, bmp->getDC(), dx, dy, SRCCOPY);
}
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
+44 -12
View File
@@ -7,6 +7,7 @@
#ifdef _WIN32
#include <cassert>
#include <string>
#include <vector>
@@ -36,15 +37,33 @@ std::string tickLabel(int db) {
return std::string(buf);
}
// The MASTER column: dB scale in the label gutter, one or two bars, the held peak tick, and
// the latched clip cap. `split` is the RESOLVED lane decision — see master_meter.h.
void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi& state,
LaneSplit split) {
if (column.width <= 0 || column.height <= 0) return;
const MeterRects m = meterRects(column, split);
// The numeral gutter, left of the bars: static content, so it is drawn on a full paint only —
// re-blending AA text onto itself every meter frame would thicken it.
void drawMeterNumerals(LICE_IBitmap* bmp, const MeterRects& m) {
if (m.field.empty()) return;
for (int db = static_cast<int>(instrument::engine::kMeterTopDb);
db >= static_cast<int>(instrument::engine::kMeterFloorDb);
db -= static_cast<int>(kMeterTickStepDb)) {
if (!meterTickNumeralled(db)) continue;
kitText(bmp, meterNumeralRect(m.labels, meterDbToY(m.field, db)), tickLabel(db).c_str(),
Font::Micro, Role::TextDim, Align::Right);
}
}
// The bar field: the dB rules, one or two bars, the held peak tick and the latched clip cap —
// everything in the column that moves at meter rate. SELF-CONTAINED on purpose: the BgCell fill
// covers every pixel the rest of it then draws, so a meter frame can redraw this rect alone.
// Two bars or one is read off the rects (barB is empty exactly when the split is Single), so
// the lane decision has one representation here rather than two.
// This self-containment depends on Role::BgCell being fully OPAQUE at InteractionState::Rest
// (theme.cpp: alpha 255) — fillGradient blends rather than overwrites at alpha < 255, so a
// translucent BgCell would make every meter frame re-blend over whatever the last frame left,
// the exact AA-thickening the numeral gutter comment below is guarding against.
void drawMeterField(LICE_IBitmap* bmp, const MeterRects& m, const MasterMeterUi& state) {
// A column narrower than the interior needs yields all-empty rects, which under rect.h's
// contract means suppressed — not a zero-height field to fill, tick twelve times and cap.
if (m.field.empty()) return;
assert(meterBarsWithinField(m)); // the self-containment invariant this draw rests on
fillSurface(bmp, toKitBox(m.field), Role::BgCell, InteractionState::Rest);
// Scale: a rule every 6 dB, numeralled every 12 with 0 dB heavier — the reference the
@@ -57,10 +76,6 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
const bool zero = (db == 0);
LICE_FillRect(bmp, m.field.x, y, m.field.width, zero ? 2 : 1,
zero ? toLice(roleColor(Role::TextDim)) : hairline, 1.0f, 0);
if (meterTickNumeralled(db)) {
kitText(bmp, meterNumeralRect(m.labels, y), tickLabel(db).c_str(), Font::Micro,
Role::TextDim, Align::Right);
}
}
// The bars. A single-lane surface shows ONE bar folding both channels per field
@@ -80,7 +95,7 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
LICE_FillRect(bmp, bar.x, hold, bar.width, 2, holdInk, 1.0f, 0);
}
};
if (split == LaneSplit::Single) {
if (m.barB.empty()) {
drawBar(m.barA, meterSingleLaneState(state));
} else {
drawBar(m.barA, state.left);
@@ -96,6 +111,18 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
} // namespace
void ReaSamplerEditor::paintMeterField(LICE_IBitmap* bmp) {
drawMeterField(bmp, meterRects_, masterMeter_);
// A full paint under the curve popup washes the whole client at 0.50 alpha AFTER the deck
// draws (paintCurvePopup); a meter-only fast-path frame draws fresh opaque bars into that
// same back buffer, so it must reapply the same wash to the field alone or the meter would
// flash through at full brightness against the dimmed rest of the face.
if (curvePopup_ != CurveTarget::kNone && !meterRects_.field.empty()) {
const Rect& f = meterRects_.field;
LICE_FillRect(bmp, f.x, f.y, f.width, f.height, toLice(roleColor(Role::BgBase)), 0.50f, 0);
}
}
void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
const Rect& deckArea = fl.bands.decks;
if (deckArea.width <= 0 || deckArea.height <= 0) return;
@@ -279,7 +306,12 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
monoTrigger_ == MonoTrigger::Legato, !isMono);
}
if (g.column.id >= 0) paintMeterColumn(bmp, g.column.box, masterMeter_, meterSplit);
if (g.column.id >= 0) {
// Cached so the meter's own tick repaints the field without re-laying out the deck.
meterRects_ = meterRects(g.column.box, meterSplit);
drawMeterNumerals(bmp, meterRects_);
drawMeterField(bmp, meterRects_, masterMeter_);
}
// The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry.
// The predicate is the input side's, so the drawn state and the inert grab agree.
@@ -13,7 +13,7 @@
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // computeEnvelope (waveform binning)
#include "core/audio/peaks.h" // Envelope (the binned waveform)
#include "core/instrument/ui/curve_tessellate.h" // buildEnvelopeTrace (the staged trace)
#include "core/instrument/ui/spline_edit.h" // splineOverlayBox (the contour's mapping box)
#include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX
@@ -24,7 +24,6 @@ namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary
using namespace reasampler::instrument::ui; // lanes + waveform geometry
using audio::computeEnvelope;
namespace {
// Marker roles — semantic, drawn through the kit's palette. The loop family is teal
@@ -203,18 +202,18 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band,
const std::size_t bins =
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
// Binned once per (capture, bin count) rather than once per paint — a full scan of the
// decoded PCM is what a frame-rate repaint cannot afford. See heroEnvelope.
if (surface.laneCount == 2) {
// ONE pass over the interleaved source: computeEnvelope already envelopes each
// channel independently, so the second lane costs no second scan of the PCM.
const Envelope env =
computeEnvelope(src.interleaved, static_cast<std::size_t>(src.channelCount),
static_cast<std::size_t>(src.frameCount()), bins);
const Envelope& env = heroEnvelope(bins, 2);
drawEnvelope(bmp, surface.upper, laneEnvelope(env, 0));
drawEnvelope(bmp, surface.lower, laneEnvelope(env, 1));
} else {
// One lane draws what one lane plays: the downmix, not channel 0 of a stereo
// source.
drawEnvelope(bmp, surface.upper, computeEnvelope(mono, 1, mono.size(), bins));
drawEnvelope(bmp, surface.upper, heroEnvelope(bins, 1));
}
}
+53 -2
View File
@@ -31,6 +31,15 @@ constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
// a per-window SetTimer id (any nonzero).
constexpr UINT_PTR kSyncTimerId = 1;
constexpr UINT kSyncTimerIntervalMs = 500;
// The meter's own clock on the same child window, because the bus meter is the one surface
// whose value changes every block. Raising the poll above to frame rate instead is the REJECTED
// alternative: its body costs a bridge read plus a bank parse, and its two tick-counted banners
// (the bake message, the drop hint) are calibrated in ticks, so they would silently shorten by
// the same factor. 16 ms is 60 FPS; what a frame costs is the meter's bar field, not the client
// area — the whole-client invalidate is what made this rate unaffordable before.
constexpr UINT_PTR kMeterTimerId = 2;
constexpr UINT kMeterTimerIntervalMs = 16;
} // namespace
#endif
@@ -66,6 +75,32 @@ void ReaSamplerEditor::invalidate() {
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
}
void ReaSamplerEditor::invalidateMeter() {
if (!childHwnd_) return;
// The meter is covered (Browse) or has nothing to draw (empty state) — ballistics still
// advance in onMeterTimer, but nothing on screen changed, so invalidating anything here
// would only buy a whole-client repaint of chrome/waveform/deck the meter never touches.
// Distinct from "bounds not resolved yet" below: this is a fact about what the face is
// showing, not about whether meterRects_ happens to be populated. The curve popup does NOT
// cover the meter — its centered sheet tops out at 520x380 (curve_popup.h) against the
// meter's fixed right-anchored slot, at any resizable size — so the meter stays on screen
// (dimmed by the popup's wash, which paintMeterField reapplies to the field alone) and keeps
// its own rate rather than freezing under the sheet.
const bool meterOnScreen = view_ == View::kSample && !selectedId_.empty();
if (!meterOnScreen) return;
const Rect& f = meterRects_.field;
if (f.empty()) {
// On screen, but no full paint has resolved its bounds yet (first paint, or a resize
// just dropped the cache) — the whole-client fallback is cheap here because the window
// is already fully invalid from the resize/creation that caused this.
invalidate();
return;
}
RECT r{f.x, f.y, f.right(), f.bottom()};
InvalidateRect(childHwnd_, &r, FALSE);
}
void ReaSamplerEditor::attachedToParent() {
HWND parent = static_cast<HWND>(systemWindow);
if (!parent) return;
@@ -111,6 +146,9 @@ void ReaSamplerEditor::attachedToParent() {
// created here, killed in removedFromParent — so an instance whose editor is closed
// does not poll.
SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr);
// Bound to the same window for the same reason: an instance with no editor open runs
// neither clock, and the meter's accumulators simply pile up until one opens.
SetTimer(childHwnd_, kMeterTimerId, kMeterTimerIntervalMs, nullptr);
// Poll once immediately so a pending assignment (an ingest fired while this editor was
// closed) or a bank change applies the instant the editor opens, rather than waiting up
// to one timer interval. refreshFromBank above already primed the view; this folds in
@@ -127,9 +165,15 @@ void ReaSamplerEditor::removedFromParent() {
if (processor_) processor_->endParamGesture();
if (childHwnd_) {
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
KillTimer(childHwnd_, kMeterTimerId);
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
releaseBackBuffer(); // a client-area bitmap outlives nothing here
// Unreachable today (WM_PAINT outranks WM_TIMER, so a reopen's first paint resolves this
// before any meter tick can read it stale) — cleared anyway so the invariant is structural,
// not a timing accident, the same reason onSize clears it on resize.
meterRects_ = {};
}
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
@@ -137,6 +181,10 @@ tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
if (childHwnd_ && newSize) {
MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them
ensureBackBuffer(newSize->getWidth(), newSize->getHeight());
// The cached meter rects name the OLD layout; drop them so the meter tick takes the
// whole-client path until the resize's own full paint resolves them again.
meterRects_ = {};
}
return res;
}
@@ -149,7 +197,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
case WM_PAINT: {
PAINTSTRUCT ps{};
HDC hdc = BeginPaint(hwnd, &ps);
if (self) self->paint(hdc);
if (self) self->paint(hdc, ps.rcPaint);
EndPaint(hwnd, &ps);
return 0;
}
@@ -293,7 +341,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
return 0;
}
case WM_TIMER:
if (self && wParam == kSyncTimerId) self->onSyncTimer();
if (self) {
if (wParam == kSyncTimerId) self->onSyncTimer();
else if (wParam == kMeterTimerId) self->onMeterTimer();
}
return 0;
case WM_ERASEBKGND:
return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase
+65 -26
View File
@@ -1,5 +1,5 @@
// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync and meter ticks, the
// commit-and-reload seam, selection loading, the loaded capture's marker resolution, and
// the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits off the audio
// thread via the processor's reloadInstrument.
@@ -7,6 +7,7 @@
#include "shell/instrument/reasampler_editor.h"
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <string>
#include <vector>
@@ -108,31 +109,6 @@ void ReaSamplerEditor::onSyncTimer() {
// tick picks up the change after release.
if (!processor_) return;
// 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 unsigned long long previous = meterTickMs_;
meterTickMs_ = now;
const MasterBusMeter bus = processor_->masterBusMeter();
// The accumulators have exactly one consumer — this tick — so with no editor open they
// hold everything since the instance was created. The first read is therefore session
// history, not a window: showing it would put the bar at the loudest peak of the
// session (instantaneous rise, then a 1.5 s hold) and light the GR lamp off a catch
// minutes old, with the limiter possibly off since. Discard it and start the window
// here. The CLIP survives, because it is a latch the user clears rather than a window —
// it is still set in the processor and the next tick reports it.
if (previous != 0) {
const instrument::ui::MasterMeterUi advanced = instrument::ui::advanceMasterMeter(
masterMeter_,
{bus.peakL, bus.peakR, bus.minGain, bus.clip},
static_cast<double>(now - previous) / 1000.0);
const bool changed = !instrument::ui::meterDrawEqual(advanced, masterMeter_);
masterMeter_ = advanced;
if (changed) invalidate();
}
}
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
// A parameter commit that flipped the limiter already changed the sound; what waits for this
@@ -202,6 +178,49 @@ void ReaSamplerEditor::onSyncTimer() {
invalidate();
}
}
void ReaSamplerEditor::onMeterTimer() {
// UI thread (WM_TIMER). Deliberately outside the sync tick's in-flight-drag guard rather
// than merely ahead of it: a drag suppresses the reload poll, but the bus keeps sounding
// and a frozen bar would misreport it.
if (!processor_) return;
const auto now = std::chrono::steady_clock::now();
const bool first = meterTick_ == std::chrono::steady_clock::time_point{};
const double elapsed =
first ? 0.0 : std::chrono::duration<double>(now - meterTick_).count();
// A zero delta advances nothing, and the drain below is CONSUMING — returning ahead of it
// keeps the window intact for the next tick instead of spending it on a frame that would
// move no pixel. GetTickCount64's ~15.6 ms granularity made that the common case at this
// rate; a steady clock makes it rare, not impossible.
if (!first && elapsed <= 0.0) return;
meterTick_ = now;
// THE one call site. masterBusMeter() exchanges the accumulators to identity as it reads,
// so a second clock reading it would steal windows from this one.
const MasterBusMeter bus = processor_->masterBusMeter();
// The accumulators have exactly one consumer — this tick — so with no editor open they
// hold everything since the instance was created. The first read is therefore session
// history, not a window: showing it would put the bar at the loudest peak of the session
// (instantaneous rise, then a 1.5 s hold) and light the GR lamp off a catch minutes old,
// with the limiter possibly off since. Discard it and start the window here. The CLIP
// survives, because it is a latch the user clears rather than a window — it is still set in
// the processor and the next tick reports it.
if (first) return;
const instrument::ui::MasterMeterUi advanced = instrument::ui::advanceMasterMeter(
masterMeter_, {bus.peakL, bus.peakR, bus.minGain, bus.clip}, elapsed);
const bool changed = !instrument::ui::meterDrawEqual(advanced, masterMeter_);
// The GR lamp sits in the deck group's caption row, outside the bar field a meter frame
// repaints, and is drawn straight onto the group's own gradient — so its rare transitions
// take the whole-client repaint rather than growing the fast path a second rect.
const bool lampMoved =
instrument::ui::grLampLit(advanced) != instrument::ui::grLampLit(masterMeter_);
masterMeter_ = advanced;
if (!changed) return;
if (lampMoved) invalidate();
else invalidateMeter();
}
#endif // _WIN32
void ReaSamplerEditor::commitAndReload() {
@@ -437,6 +456,25 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
return ins.first->second;
}
const Envelope& ReaSamplerEditor::heroEnvelope(std::size_t bins, int laneCount) {
// One lane draws the downmix binned at the band's width, which is exactly what the
// thumbnail cache already answers — so the single-lane hero costs no second slot and
// inherits that cache's busting (refreshFromBank, and a resize's clear).
if (laneCount != 2) return thumbnailFor(selectedId_, static_cast<int>(bins));
// Two lanes bin the INTERLEAVED source instead, which no thumbnail ever asks for. A resize
// changes `bins` and misses the memo; a capture change drops the decode it rides in.
const int channels = channelPcmFor(selectedId_).channelCount;
if (channelPcm_.heroBins != bins) {
channelPcm_.heroBins = bins;
channelPcm_.heroEnv = computeEnvelope(channelPcm_.interleaved,
static_cast<std::size_t>(channels),
static_cast<std::size_t>(channelPcm_.frameCount()),
bins);
}
return channelPcm_.heroEnv;
}
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
// Key through the pure ThumbnailKey (bank_grid, length-prefixed id — collision-proof) so
// both thumbnail pipelines share one tested key grammar. The editor invalidates by
@@ -468,6 +506,7 @@ ReaSamplerEditor::~ReaSamplerEditor() {
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
releaseBackBuffer(); // removedFromParent normally did; this is the un-detached path
#endif
}
+39 -17
View File
@@ -1,12 +1,12 @@
// reasampler_editor.h — VST3 IPlugView LICE editor for the ReaSampler 9000 UI. Thin shell:
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules
// (sample_bands, sample_chrome, capture_browser, keyboard_strip, sample_map). The Sample
// face is a three-band stack — chrome, waveform, decks — and the shell TUs split on that
// same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in the
// pure modules; every edit commits off the audio thread via reloadInstrument.
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules. The
// Sample face is a three-band stack — chrome, waveform, decks — and the shell TUs split on
// that same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in
// the pure modules; every edit commits off the audio thread via reloadInstrument.
#pragma once
#include <chrono>
#include <optional>
#include <string>
#include <unordered_map>
@@ -64,6 +64,9 @@ public:
// always supplies one).
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
~ReaSamplerEditor() override;
// backBuffer_ owns a raw LICE bitmap with no refcount behind it — a copy would double-free.
ReaSamplerEditor(const ReaSamplerEditor&) = delete;
ReaSamplerEditor& operator=(const ReaSamplerEditor&) = delete;
Steinberg::tresult PLUGIN_API isPlatformTypeSupported(
Steinberg::FIDString type) override;
@@ -118,7 +121,13 @@ private:
FaceLayout faceLayout(int w, int h) const;
#ifdef _WIN32
void paint(HDC hdc);
// `dirty` is the host's WM_PAINT update rect: a region inside the meter's bar field redraws
// that field alone, anything else re-composes the face, and either way only the dirty region
// is blitted out of the retained back buffer (a per-paint client-area LICE_SysBitmap is what
// the meter's rate would otherwise cost).
void paint(HDC hdc, const RECT& dirty);
LICE_IBitmap* ensureBackBuffer(int w, int h);
void releaseBackBuffer();
void paintSample(LICE_IBitmap* bmp, int w, int h); // home face (band composition)
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // modal picker overlay
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
@@ -137,6 +146,7 @@ private:
// 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);
void paintMeterField(LICE_IBitmap* bmp); // the bar field alone, off meterRects_
// A deck cell's mini curve thumbnail (the VELOCITY group) and the modal editor it summons.
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, CurveTarget target, bool disabled,
@@ -231,10 +241,16 @@ private:
// never yanks the edit surface.
void onSyncTimer();
// The meter tick (its own WM_TIMER id, UI thread only): drains the bus, advances the
// ballistics, repaints the bar field. Its own clock — see editor_platform.cpp's timer ids.
void onMeterTimer();
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
void invalidate();
void invalidateMeter(); // the bar field only; falls back to invalidate() before first paint
HWND childHwnd_ = nullptr;
LICE_IBitmap* backBuffer_ = nullptr; // owned; see ensureBackBuffer
#endif
// Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's
@@ -280,10 +296,16 @@ private:
// thread only (file I/O); cleared with the thumbnail cache on refresh.
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
// The hero waveform's binned envelope: the band's most expensive draw, and a function of the
// capture and the bin count alone. UI thread only (may decode).
const Envelope& heroEnvelope(std::size_t bins, int laneCount);
// The interleaved source PCM behind the stereo waveform lanes.
struct ChannelPcm {
std::vector<AudioSample> interleaved; // frame-interleaved source frames
int channelCount = 0; // 0 = nothing decoded
Envelope heroEnv; // binned from `interleaved`; cleared whenever it is
std::size_t heroBins = 0; // 0 = not computed
std::int64_t frameCount() const {
return channelCount > 0
? static_cast<std::int64_t>(interleaved.size()) / channelCount
@@ -313,10 +335,9 @@ private:
// 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.
// Which marks the band DRAWS, and which of those accept a grab. Trigger has no loop at all,
// so the pair and crossfade are ABSENT rather than drawn inert (an offered-but-refused
// gesture reads as broken) — drawn iff grabbable, so the two cannot currently diverge.
instrument::ui::WaveMarks waveMarksFor(const SetupMarkers& m) const;
instrument::ui::WaveMarks grabbableMarks(const SetupMarkers& m) const;
@@ -447,9 +468,7 @@ private:
// up. One note at a time — a fresh press releases the prior.
int previewingNote_ = -1;
// Resample bake. The click only ARMS it; the sync tick runs it. Running it inline
// would nest a synchronous REAPER action — which re-points this very instance — inside
// a mouse handler with the capture held.
// Resample bake — the click only ARMS it (see this directory's CLAUDE.md Gotchas for why).
bool bakePending_ = false;
// Whether the extension's bake action is registered, resolved on the same tick that
// governs the button's paint, so the control is never enabled and then refusing.
@@ -471,11 +490,14 @@ private:
std::string searchQuery_; // type-to-filter narrow; "" = no search
bool searchFocused_ = false; // whether the search box has keyboard focus
// The MASTER deck's meter, advanced from the published block magnitudes on the sync tick
// (see onSyncTimer for why it runs mid-drag too, and why the first read is discarded).
// meterTickMs_ 0 = never ticked.
// The MASTER deck's meter, advanced from the published block magnitudes on its own timer
// (see onMeterTimer for why it runs mid-drag too, and why the first read is discarded).
// A default-constructed meterTick_ means never ticked. meterRects_ is the column interior
// resolved at the last FULL paint; an empty field is ambiguous alone (no layout yet, or
// genuinely off screen) — invalidateMeter asks view_/curvePopup_/selectedId_ to tell which.
instrument::ui::MasterMeterUi masterMeter_;
unsigned long long meterTickMs_ = 0;
std::chrono::steady_clock::time_point meterTick_{};
instrument::ui::MeterRects meterRects_;
// Hover state (transient, never persisted).
HoverTarget hover_; // the interactive element under the pointer
+27
View File
@@ -5,6 +5,8 @@
// the mono bar taking the whole field, the two stereo bars, all inside the column.
// * bar count — the SAME LaneSplit resolveLaneSplit folds, over channel mode x source
// channel count, so it can never become a second rule.
// * the editor's dirty-rect fast path — meterFastPathEligible's wholly-inside test, and
// meterBarsWithinField, the self-containment invariant the fast path rests on.
// * the dB axis — top/floor on the field's edges, an interior value, and the clamps.
// * ballistics — instantaneous rise, 20 dB/s fall, the 1.5 s hold and its release AT RATE;
// the audio thread's clip latch surviving a UI frame; the per-field single-lane fold.
@@ -287,6 +289,29 @@ static void testDrawEqualityCoversTheDrawnQuantities() {
CHECK(meterDrawEqual(a, graze));
}
// The editor's WM_PAINT fast-path test: a dirty rect wholly inside the field takes the
// meter-only redraw; anything that pokes outside it, or a field that hasn't been laid out yet,
// falls back to the full compose.
static void testFastPathEligibleOnlyForADirtyRectInsideTheField() {
const MeterRects m = meterRects(kColumn, LaneSplit::Single);
CHECK(meterFastPathEligible(m, m.field)); // the whole field
CHECK(meterFastPathEligible(
m, Rect::ltrb(m.field.x + 2, m.field.y + 2, m.field.right() - 2, m.field.bottom() - 2)));
CHECK(!meterFastPathEligible(m, Rect::ltrb(m.field.x - 1, m.field.y, m.field.right(),
m.field.bottom()))); // pokes left of the field
CHECK(!meterFastPathEligible(
m, Rect::ltrb(kColumn.x, kColumn.y, kColumn.right(), kColumn.bottom()))); // whole column
CHECK(!meterFastPathEligible(MeterRects{}, m.field)); // no cached layout at all
}
// The self-containment invariant drawMeterField's redraw-the-field-alone shortcut rests on:
// every bar meterRects() hands back stays inside the field, in both lane splits.
static void testFieldBoundsEveryBarItPaints() {
CHECK(meterBarsWithinField(meterRects(kColumn, LaneSplit::Single)));
CHECK(meterBarsWithinField(meterRects(kColumn, LaneSplit::Stereo)));
CHECK(meterBarsWithinField(MeterRects{})); // degenerate: nothing to bound, nothing drawn
}
static void testDegenerateColumnYieldsNothing() {
const MeterRects m = meterRects(Rect::ltrb(0, 0, 0, 0), LaneSplit::Stereo);
CHECK(m.field.empty() && m.barA.empty() && m.barB.empty());
@@ -300,6 +325,8 @@ int main() {
testEveryOtherTickCarriesANumeral();
testTheFloorNumeralStaysInsideTheGutter();
testAColumnTooNarrowForTheInteriorDrawsNothing();
testFastPathEligibleOnlyForADirtyRectInsideTheField();
testFieldBoundsEveryBarItPaints();
testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond();
testPeakHoldSitsForItsFullWindowThenReleases();
testClipLatchesFromThePublishedFlagAndClearsOnDemand();