Merge branch 'omega-w1-t3-meter-rate' into phase-omega
This commit is contained in:
@@ -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
|
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
|
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
|
||||||
labels for them.
|
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.
|
- `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.
|
- `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.
|
- `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.
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ MeterRects meterRects(const Rect& column, LaneSplit split) {
|
|||||||
return r;
|
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) {
|
Rect meterNumeralRect(const Rect& labels, int y) {
|
||||||
if (labels.empty()) return {};
|
if (labels.empty()) return {};
|
||||||
int top = y - 5;
|
int top = y - 5;
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ struct MeterRects {
|
|||||||
// A column narrower than kMeterColumnW yields nothing rather than an overrunning field.
|
// A column narrower than kMeterColumnW yields nothing rather than an overrunning field.
|
||||||
MeterRects meterRects(const Rect& column, LaneSplit split);
|
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,
|
// y of `db` inside the bar field — kMeterTopDb at the top edge, kMeterFloorDb at the bottom,
|
||||||
// linear in dB between, clamped outside.
|
// linear in dB between, clamped outside.
|
||||||
int meterDbToY(const Rect& field, double db);
|
int meterDbToY(const Rect& field, double db);
|
||||||
|
|||||||
@@ -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.
|
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
|
Narrowing it further means a second deferral mechanism (a posted window message) rather than
|
||||||
the tick — deliberately not built.
|
the tick — deliberately not built.
|
||||||
- **The MASTER meter's ballistics ride the sync tick, and that tick is 500 ms.** They run
|
- **The MASTER meter has its OWN 16 ms timer, beside the 500 ms sync poll.** It runs outside
|
||||||
BEFORE the tick's in-flight-drag guard on purpose — a drag suppresses the reload poll, but
|
that poll's in-flight-drag guard on purpose — a drag suppresses the reload poll, but the bus
|
||||||
the bus keeps sounding. Elapsed time is measured (`GetTickCount64`), never assumed from the
|
keeps sounding. Elapsed time is measured (`steady_clock`, whose resolution the rate needs —
|
||||||
timer's period, and the tick repaints only when `meterDrawEqual` says the picture changed.
|
`GetTickCount64`'s ~15.6 ms quantized a frame's delta to 0 or 16), never assumed from the
|
||||||
**The published block state is therefore ACCUMULATED, not sampled**: at 48 kHz / 512 frames
|
timer's period, a zero delta skips the advance, and the tick repaints only when
|
||||||
~47 blocks elapse per tick, so the processor folds a per-channel max and a min limiter gain
|
`meterDrawEqual` says the picture changed. **The published block state is ACCUMULATED, not
|
||||||
across them and `masterBusMeter()` clears the accumulators as it reads. A plain overwriting
|
sampled**: the processor folds a per-channel max and a min limiter gain across every block
|
||||||
store displayed one block in ~47 and lost the rest — the specified "a peak displays on the
|
since the last read and `masterBusMeter()` clears the accumulators as it reads. A plain
|
||||||
first UI frame after it occurs" is what the fold restores. `masterBusMeter()` is CONSUMING,
|
overwriting store displayed one block per window and lost the rest — the specified "a peak
|
||||||
so exactly one caller may hold it; the embed strip reads its own non-consuming
|
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
|
`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
|
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
|
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
|
latch is not discarded with them — it is a latch the user clears.
|
||||||
separate change and is not in.
|
- **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
|
- 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
|
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
|
applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// editor_paint.cpp — the ReaSamplerEditor's paint dispatch: the WM_PAINT entry, the Sample
|
// editor_paint.cpp — the ReaSamplerEditor's paint dispatch: the WM_PAINT entry over a retained
|
||||||
// face's band composition (chrome / waveform / decks, each drawn by its own TU), the empty
|
// back buffer (with the meter's dirty-rect fast path), the Sample face's band composition
|
||||||
// state, and the drop-affordance banner. Windows-only; draws through the shared kit by
|
// (chrome / waveform / decks, each drawn by its own TU), the empty state, and the
|
||||||
// palette role. All layout math is pure (sample_bands) — this TU only sequences.
|
// 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"
|
#include "shell/instrument/reasampler_editor.h"
|
||||||
|
|
||||||
@@ -18,37 +19,76 @@ namespace reasampler::vst {
|
|||||||
using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …)
|
using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …)
|
||||||
using namespace reasampler::instrument::ui; // pure geometry (bands / chrome)
|
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{};
|
RECT cr{};
|
||||||
GetClientRect(childHwnd_, &cr);
|
GetClientRect(childHwnd_, &cr);
|
||||||
const int w = cr.right - cr.left;
|
const int w = cr.right - cr.left;
|
||||||
const int h = cr.bottom - cr.top;
|
const int h = cr.bottom - cr.top;
|
||||||
if (w <= 0 || h <= 0) return;
|
if (w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
LICE_SysBitmap bmp(w, h);
|
LICE_IBitmap* bmp = ensureBackBuffer(w, h);
|
||||||
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
|
if (!bmp) return;
|
||||||
|
|
||||||
// Sample is home; Browse is a full-window modal overlay drawn over it, so the Sample
|
// The host's update rect, clipped to the client area.
|
||||||
// face draws first and the modal reads as a sheet layered on top.
|
const int dx = (std::max)(0, static_cast<int>(dirty.left));
|
||||||
paintSample(&bmp, w, h);
|
const int dy = (std::max)(0, static_cast<int>(dirty.top));
|
||||||
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
|
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 transient banner flashed after a file was dropped on this window. It reiterates the
|
// A meter frame's dirty rect lies wholly inside the bar field, and nothing else is drawn
|
||||||
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
|
// there — so the field is all that needs redrawing, and the rest of the face is still in the
|
||||||
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
// back buffer from the last full compose. Decided GEOMETRICALLY rather than by a flag — see
|
||||||
if (dropHintTicks_ > 0) {
|
// meterFastPathEligible (master_meter) for why.
|
||||||
const int bannerTop = (std::min)(kTitleHeight, h);
|
if (meterFastPathEligible(meterRects_, Rect::ltrb(dx, dy, dr, db))) {
|
||||||
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
|
paintMeterField(bmp);
|
||||||
Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH);
|
} else {
|
||||||
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
|
// Repopulated by paintDeck when the meter is on screen; left empty by the empty state.
|
||||||
// hue with a dark label so it reads as "attention, not action".
|
meterRects_ = {};
|
||||||
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
|
LICE_Clear(bmp, toLice(roleColor(Role::BgBase)));
|
||||||
kitTextCentered(&bmp, banner,
|
|
||||||
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
|
// Sample is home; Browse is a full-window modal overlay drawn over it, so the Sample
|
||||||
Font::Label, Role::BgBase);
|
// 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);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||||||
|
if (dropHintTicks_ > 0) {
|
||||||
|
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,
|
||||||
|
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
|
||||||
|
Font::Label, Role::BgBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
BitBlt(hdc, dx, dy, dr - dx, db - dy, bmp->getDC(), dx, dy, SRCCOPY);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -36,15 +37,33 @@ std::string tickLabel(int db) {
|
|||||||
return std::string(buf);
|
return std::string(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The MASTER column: dB scale in the label gutter, one or two bars, the held peak tick, and
|
// The numeral gutter, left of the bars: static content, so it is drawn on a full paint only —
|
||||||
// the latched clip cap. `split` is the RESOLVED lane decision — see master_meter.h.
|
// re-blending AA text onto itself every meter frame would thicken it.
|
||||||
void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi& state,
|
void drawMeterNumerals(LICE_IBitmap* bmp, const MeterRects& m) {
|
||||||
LaneSplit split) {
|
if (m.field.empty()) return;
|
||||||
if (column.width <= 0 || column.height <= 0) return;
|
for (int db = static_cast<int>(instrument::engine::kMeterTopDb);
|
||||||
const MeterRects m = meterRects(column, split);
|
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
|
// 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.
|
// contract means suppressed — not a zero-height field to fill, tick twelve times and cap.
|
||||||
if (m.field.empty()) return;
|
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);
|
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
|
// 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);
|
const bool zero = (db == 0);
|
||||||
LICE_FillRect(bmp, m.field.x, y, m.field.width, zero ? 2 : 1,
|
LICE_FillRect(bmp, m.field.x, y, m.field.width, zero ? 2 : 1,
|
||||||
zero ? toLice(roleColor(Role::TextDim)) : hairline, 1.0f, 0);
|
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
|
// 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);
|
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));
|
drawBar(m.barA, meterSingleLaneState(state));
|
||||||
} else {
|
} else {
|
||||||
drawBar(m.barA, state.left);
|
drawBar(m.barA, state.left);
|
||||||
@@ -96,6 +111,18 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
|
|||||||
|
|
||||||
} // namespace
|
} // 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) {
|
void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||||
const Rect& deckArea = fl.bands.decks;
|
const Rect& deckArea = fl.bands.decks;
|
||||||
if (deckArea.width <= 0 || deckArea.height <= 0) return;
|
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);
|
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 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.
|
// The predicate is the input side's, so the drawn state and the inert grab agree.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#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/curve_tessellate.h" // buildEnvelopeTrace (the staged trace)
|
||||||
#include "core/instrument/ui/spline_edit.h" // splineOverlayBox (the contour's mapping box)
|
#include "core/instrument/ui/spline_edit.h" // splineOverlayBox (the contour's mapping box)
|
||||||
#include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX
|
#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::ui; // kit vocabulary
|
||||||
using namespace reasampler::instrument::ui; // lanes + waveform geometry
|
using namespace reasampler::instrument::ui; // lanes + waveform geometry
|
||||||
using audio::computeEnvelope;
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// Marker roles — semantic, drawn through the kit's palette. The loop family is teal
|
// 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 =
|
const std::size_t bins =
|
||||||
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
|
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) {
|
if (surface.laneCount == 2) {
|
||||||
// ONE pass over the interleaved source: computeEnvelope already envelopes each
|
// ONE pass over the interleaved source: computeEnvelope already envelopes each
|
||||||
// channel independently, so the second lane costs no second scan of the PCM.
|
// channel independently, so the second lane costs no second scan of the PCM.
|
||||||
const Envelope env =
|
const Envelope& env = heroEnvelope(bins, 2);
|
||||||
computeEnvelope(src.interleaved, static_cast<std::size_t>(src.channelCount),
|
|
||||||
static_cast<std::size_t>(src.frameCount()), bins);
|
|
||||||
drawEnvelope(bmp, surface.upper, laneEnvelope(env, 0));
|
drawEnvelope(bmp, surface.upper, laneEnvelope(env, 0));
|
||||||
drawEnvelope(bmp, surface.lower, laneEnvelope(env, 1));
|
drawEnvelope(bmp, surface.lower, laneEnvelope(env, 1));
|
||||||
} else {
|
} else {
|
||||||
// One lane draws what one lane plays: the downmix, not channel 0 of a stereo
|
// One lane draws what one lane plays: the downmix, not channel 0 of a stereo
|
||||||
// source.
|
// source.
|
||||||
drawEnvelope(bmp, surface.upper, computeEnvelope(mono, 1, mono.size(), bins));
|
drawEnvelope(bmp, surface.upper, heroEnvelope(bins, 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
|
|||||||
// a per-window SetTimer id (any nonzero).
|
// a per-window SetTimer id (any nonzero).
|
||||||
constexpr UINT_PTR kSyncTimerId = 1;
|
constexpr UINT_PTR kSyncTimerId = 1;
|
||||||
constexpr UINT kSyncTimerIntervalMs = 500;
|
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
|
} // namespace
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -66,6 +75,32 @@ void ReaSamplerEditor::invalidate() {
|
|||||||
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
|
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() {
|
void ReaSamplerEditor::attachedToParent() {
|
||||||
HWND parent = static_cast<HWND>(systemWindow);
|
HWND parent = static_cast<HWND>(systemWindow);
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
@@ -111,6 +146,9 @@ void ReaSamplerEditor::attachedToParent() {
|
|||||||
// created here, killed in removedFromParent — so an instance whose editor is closed
|
// created here, killed in removedFromParent — so an instance whose editor is closed
|
||||||
// does not poll.
|
// does not poll.
|
||||||
SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr);
|
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
|
// 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
|
// 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
|
// 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 (processor_) processor_->endParamGesture();
|
||||||
if (childHwnd_) {
|
if (childHwnd_) {
|
||||||
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
|
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
|
||||||
|
KillTimer(childHwnd_, kMeterTimerId);
|
||||||
DestroyWindow(childHwnd_);
|
DestroyWindow(childHwnd_);
|
||||||
childHwnd_ = nullptr;
|
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) {
|
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
|
||||||
@@ -137,6 +181,10 @@ tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
|
|||||||
if (childHwnd_ && newSize) {
|
if (childHwnd_ && newSize) {
|
||||||
MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
|
MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
|
||||||
thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them
|
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;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -149,7 +197,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
|||||||
case WM_PAINT: {
|
case WM_PAINT: {
|
||||||
PAINTSTRUCT ps{};
|
PAINTSTRUCT ps{};
|
||||||
HDC hdc = BeginPaint(hwnd, &ps);
|
HDC hdc = BeginPaint(hwnd, &ps);
|
||||||
if (self) self->paint(hdc);
|
if (self) self->paint(hdc, ps.rcPaint);
|
||||||
EndPaint(hwnd, &ps);
|
EndPaint(hwnd, &ps);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -293,7 +341,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
if (self && wParam == kSyncTimerId) self->onSyncTimer();
|
if (self) {
|
||||||
|
if (wParam == kSyncTimerId) self->onSyncTimer();
|
||||||
|
else if (wParam == kMeterTimerId) self->onMeterTimer();
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
case WM_ERASEBKGND:
|
case WM_ERASEBKGND:
|
||||||
return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase
|
return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the
|
// 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
|
// 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
|
// the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits off the audio
|
||||||
// thread via the processor's reloadInstrument.
|
// thread via the processor's reloadInstrument.
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "shell/instrument/reasampler_editor.h"
|
#include "shell/instrument/reasampler_editor.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -108,31 +109,6 @@ void ReaSamplerEditor::onSyncTimer() {
|
|||||||
// tick picks up the change after release.
|
// tick picks up the change after release.
|
||||||
if (!processor_) return;
|
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
|
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
|
// A parameter commit that flipped the limiter already changed the sound; what waits for this
|
||||||
@@ -202,6 +178,49 @@ void ReaSamplerEditor::onSyncTimer() {
|
|||||||
invalidate();
|
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
|
#endif // _WIN32
|
||||||
|
|
||||||
void ReaSamplerEditor::commitAndReload() {
|
void ReaSamplerEditor::commitAndReload() {
|
||||||
@@ -437,6 +456,25 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
|||||||
return ins.first->second;
|
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) {
|
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
|
||||||
// Key through the pure ThumbnailKey (bank_grid, length-prefixed id — collision-proof) so
|
// 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
|
// both thumbnail pipelines share one tested key grammar. The editor invalidates by
|
||||||
@@ -468,6 +506,7 @@ ReaSamplerEditor::~ReaSamplerEditor() {
|
|||||||
DestroyWindow(childHwnd_);
|
DestroyWindow(childHwnd_);
|
||||||
childHwnd_ = nullptr;
|
childHwnd_ = nullptr;
|
||||||
}
|
}
|
||||||
|
releaseBackBuffer(); // removedFromParent normally did; this is the un-detached path
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// reasampler_editor.h — VST3 IPlugView LICE editor for the ReaSampler 9000 UI. Thin shell:
|
// 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
|
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules. The
|
||||||
// (sample_bands, sample_chrome, capture_browser, keyboard_strip, sample_map). The Sample
|
// Sample face is a three-band stack — chrome, waveform, decks — and the shell TUs split on
|
||||||
// face is a three-band stack — chrome, waveform, decks — and the shell TUs split on that
|
// that same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in
|
||||||
// same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in the
|
// the pure modules; every edit commits off the audio thread via reloadInstrument.
|
||||||
// pure modules; every edit commits off the audio thread via reloadInstrument.
|
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
@@ -64,6 +64,9 @@ public:
|
|||||||
// always supplies one).
|
// always supplies one).
|
||||||
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
||||||
~ReaSamplerEditor() override;
|
~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::tresult PLUGIN_API isPlatformTypeSupported(
|
||||||
Steinberg::FIDString type) override;
|
Steinberg::FIDString type) override;
|
||||||
@@ -118,7 +121,13 @@ private:
|
|||||||
FaceLayout faceLayout(int w, int h) const;
|
FaceLayout faceLayout(int w, int h) const;
|
||||||
|
|
||||||
#ifdef _WIN32
|
#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 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 paintBrowse(LICE_IBitmap* bmp, int w, int h); // modal picker overlay
|
||||||
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
|
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
|
||||||
@@ -137,6 +146,7 @@ private:
|
|||||||
// Decks: the group fence + caption + compact caption toggles + radial knobs with
|
// Decks: the group fence + caption + compact caption toggles + radial knobs with
|
||||||
// label<->value swap on hover/drag.
|
// label<->value swap on hover/drag.
|
||||||
void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl);
|
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.
|
// 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,
|
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, CurveTarget target, bool disabled,
|
||||||
@@ -231,10 +241,16 @@ private:
|
|||||||
// never yanks the edit surface.
|
// never yanks the edit surface.
|
||||||
void onSyncTimer();
|
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);
|
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||||
void invalidate();
|
void invalidate();
|
||||||
|
void invalidateMeter(); // the bar field only; falls back to invalidate() before first paint
|
||||||
|
|
||||||
HWND childHwnd_ = nullptr;
|
HWND childHwnd_ = nullptr;
|
||||||
|
LICE_IBitmap* backBuffer_ = nullptr; // owned; see ensureBackBuffer
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's
|
// 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.
|
// thread only (file I/O); cleared with the thumbnail cache on refresh.
|
||||||
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
|
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.
|
// The interleaved source PCM behind the stereo waveform lanes.
|
||||||
struct ChannelPcm {
|
struct ChannelPcm {
|
||||||
std::vector<AudioSample> interleaved; // frame-interleaved source frames
|
std::vector<AudioSample> interleaved; // frame-interleaved source frames
|
||||||
int channelCount = 0; // 0 = nothing decoded
|
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 {
|
std::int64_t frameCount() const {
|
||||||
return channelCount > 0
|
return channelCount > 0
|
||||||
? static_cast<std::int64_t>(interleaved.size()) / channelCount
|
? static_cast<std::int64_t>(interleaved.size()) / channelCount
|
||||||
@@ -313,10 +335,9 @@ private:
|
|||||||
// fold, so a drawn envelope disables them through the same predicate.
|
// fold, so a drawn envelope disables them through the same predicate.
|
||||||
bool loopControlsLive() const;
|
bool loopControlsLive() const;
|
||||||
|
|
||||||
// Which marks the band DRAWS, and which of those accept a grab. They differ in exactly one
|
// Which marks the band DRAWS, and which of those accept a grab. Trigger has no loop at all,
|
||||||
// place — Trigger, where the loop marks stay drawn (hiding a set loop on a mode flip would
|
// so the pair and crossfade are ABSENT rather than drawn inert (an offered-but-refused
|
||||||
// destroy information the user put there) but refuse every gesture, because that refusal
|
// gesture reads as broken) — drawn iff grabbable, so the two cannot currently diverge.
|
||||||
// comes from the engine and no drag can talk it out of it.
|
|
||||||
instrument::ui::WaveMarks waveMarksFor(const SetupMarkers& m) const;
|
instrument::ui::WaveMarks waveMarksFor(const SetupMarkers& m) const;
|
||||||
instrument::ui::WaveMarks grabbableMarks(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.
|
// up. One note at a time — a fresh press releases the prior.
|
||||||
int previewingNote_ = -1;
|
int previewingNote_ = -1;
|
||||||
|
|
||||||
// Resample bake. The click only ARMS it; the sync tick runs it. Running it inline
|
// Resample bake — the click only ARMS it (see this directory's CLAUDE.md Gotchas for why).
|
||||||
// would nest a synchronous REAPER action — which re-points this very instance — inside
|
|
||||||
// a mouse handler with the capture held.
|
|
||||||
bool bakePending_ = false;
|
bool bakePending_ = false;
|
||||||
// Whether the extension's bake action is registered, resolved on the same tick that
|
// 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.
|
// 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
|
std::string searchQuery_; // type-to-filter narrow; "" = no search
|
||||||
bool searchFocused_ = false; // whether the search box has keyboard focus
|
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
|
// The MASTER deck's meter, advanced from the published block magnitudes on its own timer
|
||||||
// (see onSyncTimer for why it runs mid-drag too, and why the first read is discarded).
|
// (see onMeterTimer for why it runs mid-drag too, and why the first read is discarded).
|
||||||
// meterTickMs_ 0 = never ticked.
|
// 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_;
|
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).
|
// Hover state (transient, never persisted).
|
||||||
HoverTarget hover_; // the interactive element under the pointer
|
HoverTarget hover_; // the interactive element under the pointer
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
// the mono bar taking the whole field, the two stereo bars, all inside the column.
|
// 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
|
// * bar count — the SAME LaneSplit resolveLaneSplit folds, over channel mode x source
|
||||||
// channel count, so it can never become a second rule.
|
// 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.
|
// * 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;
|
// * 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.
|
// 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));
|
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() {
|
static void testDegenerateColumnYieldsNothing() {
|
||||||
const MeterRects m = meterRects(Rect::ltrb(0, 0, 0, 0), LaneSplit::Stereo);
|
const MeterRects m = meterRects(Rect::ltrb(0, 0, 0, 0), LaneSplit::Stereo);
|
||||||
CHECK(m.field.empty() && m.barA.empty() && m.barB.empty());
|
CHECK(m.field.empty() && m.barA.empty() && m.barB.empty());
|
||||||
@@ -300,6 +325,8 @@ int main() {
|
|||||||
testEveryOtherTickCarriesANumeral();
|
testEveryOtherTickCarriesANumeral();
|
||||||
testTheFloorNumeralStaysInsideTheGutter();
|
testTheFloorNumeralStaysInsideTheGutter();
|
||||||
testAColumnTooNarrowForTheInteriorDrawsNothing();
|
testAColumnTooNarrowForTheInteriorDrawsNothing();
|
||||||
|
testFastPathEligibleOnlyForADirtyRectInsideTheField();
|
||||||
|
testFieldBoundsEveryBarItPaints();
|
||||||
testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond();
|
testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond();
|
||||||
testPeakHoldSitsForItsFullWindowThenReleases();
|
testPeakHoldSitsForItsFullWindowThenReleases();
|
||||||
testClipLatchesFromThePublishedFlagAndClearsOnDemand();
|
testClipLatchesFromThePublishedFlagAndClearsOnDemand();
|
||||||
|
|||||||
Reference in New Issue
Block a user